lorebase-store 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 (163) hide show
  1. lorebase_store/__init__.py +1 -0
  2. lorebase_store/_migrations/env.py +106 -0
  3. lorebase_store/_migrations/script.py.mako +27 -0
  4. lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
  5. lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
  6. lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
  7. lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
  8. lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
  9. lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
  10. lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
  11. lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
  12. lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
  13. lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
  14. lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
  15. lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
  16. lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
  17. lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
  18. lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
  19. lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
  20. lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
  21. lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
  22. lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
  23. lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
  24. lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
  25. lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
  26. lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
  27. lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
  28. lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
  29. lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
  30. lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
  31. lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
  32. lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
  33. lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
  34. lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
  35. lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
  36. lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
  37. lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
  38. lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
  39. lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
  40. lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
  41. lorebase_store/auth/__init__.py +52 -0
  42. lorebase_store/auth/api_keys.py +161 -0
  43. lorebase_store/auth/audit.py +382 -0
  44. lorebase_store/auth/checkpoints.py +185 -0
  45. lorebase_store/auth/grants.py +161 -0
  46. lorebase_store/auth/principals.py +158 -0
  47. lorebase_store/auth/scope.py +70 -0
  48. lorebase_store/auth/sessions.py +153 -0
  49. lorebase_store/auth/unit.py +53 -0
  50. lorebase_store/auth/usage.py +70 -0
  51. lorebase_store/blobs.py +73 -0
  52. lorebase_store/bm25.py +346 -0
  53. lorebase_store/bulk.py +147 -0
  54. lorebase_store/bundle.py +1830 -0
  55. lorebase_store/changefeed.py +261 -0
  56. lorebase_store/commit/__init__.py +42 -0
  57. lorebase_store/commit/changes.py +67 -0
  58. lorebase_store/commit/errors.py +60 -0
  59. lorebase_store/commit/migrations.py +335 -0
  60. lorebase_store/commit/pipeline.py +754 -0
  61. lorebase_store/commit/schema.py +123 -0
  62. lorebase_store/db.py +58 -0
  63. lorebase_store/definer.py +50 -0
  64. lorebase_store/digests.py +99 -0
  65. lorebase_store/embeddings.py +444 -0
  66. lorebase_store/endpoint_modes.py +59 -0
  67. lorebase_store/extraction/__init__.py +61 -0
  68. lorebase_store/extraction/items.py +187 -0
  69. lorebase_store/extraction/materialize.py +364 -0
  70. lorebase_store/extraction/proposal.py +65 -0
  71. lorebase_store/extraction/review.py +168 -0
  72. lorebase_store/gc.py +970 -0
  73. lorebase_store/indexes.py +1033 -0
  74. lorebase_store/jobs.py +651 -0
  75. lorebase_store/keyring.py +72 -0
  76. lorebase_store/layout.py +335 -0
  77. lorebase_store/materialize/__init__.py +103 -0
  78. lorebase_store/materialize/apply.py +436 -0
  79. lorebase_store/materialize/background.py +1184 -0
  80. lorebase_store/materialize/branch.py +268 -0
  81. lorebase_store/materialize/build.py +536 -0
  82. lorebase_store/materialize/derived.py +540 -0
  83. lorebase_store/materialize/lineage_backfill.py +502 -0
  84. lorebase_store/materialize/links.py +266 -0
  85. lorebase_store/materialize/overlay.py +292 -0
  86. lorebase_store/materialize/paths.py +315 -0
  87. lorebase_store/materialize/rebuild.py +326 -0
  88. lorebase_store/materialize/rows.py +153 -0
  89. lorebase_store/materialize/sensitivity.py +66 -0
  90. lorebase_store/materialize/snapshot.py +205 -0
  91. lorebase_store/materialize/stale.py +444 -0
  92. lorebase_store/materialize/verify.py +321 -0
  93. lorebase_store/merging/__init__.py +39 -0
  94. lorebase_store/merging/attempts.py +334 -0
  95. lorebase_store/merging/lineage.py +123 -0
  96. lorebase_store/merging/service.py +652 -0
  97. lorebase_store/migrations.py +133 -0
  98. lorebase_store/models/__init__.py +136 -0
  99. lorebase_store/models/base.py +64 -0
  100. lorebase_store/models/enums.py +132 -0
  101. lorebase_store/models/governance.py +268 -0
  102. lorebase_store/models/ingestion.py +288 -0
  103. lorebase_store/models/materialized.py +277 -0
  104. lorebase_store/models/proposals.py +195 -0
  105. lorebase_store/models/queues.py +87 -0
  106. lorebase_store/models/tracing.py +116 -0
  107. lorebase_store/models/versioning.py +222 -0
  108. lorebase_store/models/webhooks.py +96 -0
  109. lorebase_store/nodestore.py +384 -0
  110. lorebase_store/objectstore.py +162 -0
  111. lorebase_store/outbox_events.py +166 -0
  112. lorebase_store/profiling/__init__.py +58 -0
  113. lorebase_store/profiling/delimited.py +109 -0
  114. lorebase_store/profiling/postgres.py +211 -0
  115. lorebase_store/profiling/record_files.py +127 -0
  116. lorebase_store/profiling/records.py +92 -0
  117. lorebase_store/profiling/sql_databases.py +298 -0
  118. lorebase_store/ratelimit.py +129 -0
  119. lorebase_store/reads.py +1491 -0
  120. lorebase_store/replicas.py +434 -0
  121. lorebase_store/repositories/__init__.py +152 -0
  122. lorebase_store/repositories/changesets.py +59 -0
  123. lorebase_store/repositories/deferred_events.py +142 -0
  124. lorebase_store/repositories/documents.py +305 -0
  125. lorebase_store/repositories/domains.py +97 -0
  126. lorebase_store/repositories/events.py +205 -0
  127. lorebase_store/repositories/index_view.py +489 -0
  128. lorebase_store/repositories/invocations.py +68 -0
  129. lorebase_store/repositories/materializations.py +276 -0
  130. lorebase_store/repositories/materialized.py +1129 -0
  131. lorebase_store/repositories/merges.py +417 -0
  132. lorebase_store/repositories/proposal_items.py +199 -0
  133. lorebase_store/repositories/proposals.py +184 -0
  134. lorebase_store/repositories/provenance.py +77 -0
  135. lorebase_store/repositories/refs.py +452 -0
  136. lorebase_store/repositories/revisions.py +273 -0
  137. lorebase_store/repositories/scope.py +33 -0
  138. lorebase_store/repositories/source_runs.py +123 -0
  139. lorebase_store/repositories/suggestions.py +638 -0
  140. lorebase_store/runtime.py +87 -0
  141. lorebase_store/schema_cache.py +50 -0
  142. lorebase_store/settings.py +879 -0
  143. lorebase_store/storage.py +1478 -0
  144. lorebase_store/telemetry.py +142 -0
  145. lorebase_store/testing.py +108 -0
  146. lorebase_store/tracing.py +505 -0
  147. lorebase_store/traverse.py +578 -0
  148. lorebase_store/vault/__init__.py +50 -0
  149. lorebase_store/vault/keys.py +216 -0
  150. lorebase_store/vault/quotes.py +133 -0
  151. lorebase_store/vault/rotation.py +209 -0
  152. lorebase_store/vault/settings.py +152 -0
  153. lorebase_store/vault/store.py +987 -0
  154. lorebase_store/vault/transfer.py +558 -0
  155. lorebase_store/webhooks/__init__.py +24 -0
  156. lorebase_store/webhooks/delivery.py +637 -0
  157. lorebase_store/webhooks/keys.py +126 -0
  158. lorebase_store/webhooks/repository.py +142 -0
  159. lorebase_store/webhooks/settings.py +138 -0
  160. lorebase_store/webhooks/signing.py +50 -0
  161. lorebase_store-0.2.3.dist-info/METADATA +13 -0
  162. lorebase_store-0.2.3.dist-info/RECORD +163 -0
  163. lorebase_store-0.2.3.dist-info/WHEEL +4 -0
@@ -0,0 +1,261 @@
1
+ """Revision events: what a commit changed, for the change feed and webhooks (design 10.5).
2
+
3
+ The commit pipeline writes one `revision.committed` outbox event per Revision (branch,
4
+ Revision, parents, change counts). `describe` turns Revisions into the event documents the
5
+ change feed and webhooks send: the Revision metadata, changes per Kind and Relation (api
6
+ names), whether the schema changed, and up to `keys_max` changed Record keys.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections import defaultdict
12
+ from collections.abc import Collection, Iterable, Mapping, Sequence
13
+ from dataclasses import dataclass, field
14
+ from datetime import UTC
15
+ from typing import Any
16
+
17
+ from sqlalchemy import Table, and_, func, select, text
18
+ from sqlalchemy.ext.asyncio import AsyncSession
19
+
20
+ from lorebase_core.model import Schema
21
+ from lorebase_store.commit.pipeline import COMMITTED_EVENT
22
+ from lorebase_store.commit.schema import load_compiled
23
+ from lorebase_store.materialize.rows import PartitionType, parse_partition
24
+ from lorebase_store.models import metadata
25
+ from lorebase_store.repositories.materialized import MaterializedRepository
26
+ from lorebase_store.repositories.revisions import RevisionRepository, RevisionRow
27
+ from lorebase_store.settings import WriteSettings
28
+
29
+ CHANGESETS: Table = metadata.tables["changesets"]
30
+ EVENT_TYPES: tuple[str, ...] = (COMMITTED_EVENT,)
31
+ """Event types of the change feed (Revision events; see `outbox_events` for webhooks)."""
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class RevisionChanges:
36
+ """Changed elements of one Revision: counts per partition, some Record keys."""
37
+
38
+ partitions: Mapping[str, int] = field(default_factory=dict)
39
+ keys: Mapping[str, Sequence[str]] = field(default_factory=dict)
40
+ """Changed Record keys per Kind partition, in key order, `keys_max` in all."""
41
+ keys_truncated: bool = False
42
+
43
+
44
+ async def revision_changes(
45
+ session: AsyncSession,
46
+ domain_id: str,
47
+ revisions: Collection[bytes],
48
+ *,
49
+ keys_max: int,
50
+ kind_partitions: Collection[str] | None = None,
51
+ ) -> dict[bytes, RevisionChanges]:
52
+ """Changes of each Revision against its first parent (its stored changeset).
53
+
54
+ `kind_partitions` restricts the keys listed (not the counts) to those Kinds.
55
+ """
56
+ if not revisions:
57
+ return {}
58
+ c = CHANGESETS
59
+ scope = and_(c.c.domain_id == domain_id, c.c.revision_id.in_(list(revisions)))
60
+ counts: dict[bytes, dict[str, int]] = defaultdict(dict)
61
+ result = await session.execute(
62
+ select(c.c.revision_id, c.c.partition, func.count())
63
+ .where(scope)
64
+ .group_by(c.c.revision_id, c.c.partition)
65
+ )
66
+ for revision, partition, count in result:
67
+ counts[bytes(revision)][partition] = int(count)
68
+ keys: dict[bytes, dict[str, list[str]]] = defaultdict(lambda: defaultdict(list))
69
+ truncated: set[bytes] = set()
70
+ if keys_max > 0:
71
+ records = c.c.partition.startswith("k:")
72
+ if kind_partitions is not None:
73
+ records = c.c.partition.in_(list(kind_partitions))
74
+ ranked = (
75
+ select(
76
+ c.c.revision_id,
77
+ c.c.partition,
78
+ c.c.element_key,
79
+ func.row_number()
80
+ .over(partition_by=c.c.revision_id, order_by=(c.c.partition, c.c.element_key))
81
+ .label("rank"),
82
+ )
83
+ .where(scope, records)
84
+ .subquery()
85
+ )
86
+ rows = await session.execute(
87
+ select(ranked.c.revision_id, ranked.c.partition, ranked.c.element_key)
88
+ .where(ranked.c.rank <= keys_max + 1)
89
+ .order_by(ranked.c.revision_id, ranked.c.rank)
90
+ )
91
+ listed: dict[bytes, int] = defaultdict(int)
92
+ for revision, partition, key in rows:
93
+ revision = bytes(revision)
94
+ if listed[revision] == keys_max:
95
+ truncated.add(revision)
96
+ continue
97
+ listed[revision] += 1
98
+ keys[revision][partition].append(key)
99
+ return {
100
+ revision: RevisionChanges(
101
+ counts.get(revision, {}),
102
+ {p: list(k) for p, k in keys.get(revision, {}).items()},
103
+ revision in truncated,
104
+ )
105
+ for revision in revisions
106
+ }
107
+
108
+
109
+ def _named(schema: Schema | None, partition: str) -> tuple[PartitionType, str]:
110
+ parsed = parse_partition(partition)
111
+ if schema is not None:
112
+ if parsed.type is PartitionType.KIND and parsed.element_id in schema.kinds:
113
+ return parsed.type, schema.kinds[parsed.element_id].api_name
114
+ if parsed.type is PartitionType.RELATION and parsed.element_id in schema.relations:
115
+ return parsed.type, schema.relations[parsed.element_id].api_name
116
+ return parsed.type, parsed.element_id
117
+
118
+
119
+ def revision_event(
120
+ revision: RevisionRow,
121
+ changes: RevisionChanges,
122
+ *,
123
+ domain_id: str,
124
+ branch: str,
125
+ schema: Schema | None,
126
+ sequence: int | None = None,
127
+ ) -> dict[str, Any]:
128
+ """The JSON document of a `revision.committed` event."""
129
+ kinds: dict[str, int] = {}
130
+ relations: dict[str, int] = {}
131
+ schema_changed = False
132
+ for partition, count in sorted(changes.partitions.items()):
133
+ element, name = _named(schema, partition)
134
+ if element is PartitionType.KIND:
135
+ kinds[name] = kinds.get(name, 0) + count
136
+ elif element is PartitionType.RELATION:
137
+ relations[name] = relations.get(name, 0) + count
138
+ else:
139
+ schema_changed = True
140
+ keys = {_named(schema, p)[1]: list(k) for p, k in sorted(changes.keys.items())}
141
+ document: dict[str, Any] = {}
142
+ if sequence is not None:
143
+ document["sequence"] = sequence
144
+ document.update(
145
+ {
146
+ "type": COMMITTED_EVENT,
147
+ "domain": domain_id,
148
+ "branch": branch,
149
+ "revision": revision.id.hex(),
150
+ "parents": [p.hex() for p in revision.parents],
151
+ "generation": revision.generation,
152
+ "author": revision.author,
153
+ }
154
+ )
155
+ if revision.on_behalf_of is not None:
156
+ document["on_behalf_of"] = revision.on_behalf_of
157
+ document.update(
158
+ {
159
+ "message": revision.message,
160
+ "origin": revision.origin,
161
+ "stats": revision.stats,
162
+ "created_at": revision.created_at.astimezone(UTC).isoformat().replace("+00:00", "Z"),
163
+ "kinds": kinds,
164
+ "relations": relations,
165
+ "schema_changed": schema_changed,
166
+ "keys": keys,
167
+ "keys_truncated": changes.keys_truncated,
168
+ }
169
+ )
170
+ return document
171
+
172
+
173
+ @dataclass(frozen=True, slots=True)
174
+ class EventRef:
175
+ """A Revision committed on a branch, with its outbox sequence when known."""
176
+
177
+ revision: bytes
178
+ branch: str
179
+ sequence: int | None = None
180
+
181
+
182
+ async def describe(
183
+ session: AsyncSession,
184
+ domain_id: str,
185
+ refs: Sequence[EventRef],
186
+ *,
187
+ schemas: Mapping[str, Schema | None],
188
+ keys_max: int,
189
+ kind_partitions: Collection[str] | None = None,
190
+ ) -> list[dict[str, Any]]:
191
+ """Event documents of `refs`, in their order; Revisions that no longer exist are skipped.
192
+
193
+ `schemas` maps each branch to the schema naming its Kinds and Relations.
194
+ """
195
+ repository = RevisionRepository(session, domain_id)
196
+ rows: dict[bytes, RevisionRow] = {}
197
+ for ref in refs:
198
+ if ref.revision not in rows:
199
+ found = await repository.get(ref.revision)
200
+ if found is not None:
201
+ rows[ref.revision] = found
202
+ changes = await revision_changes(
203
+ session, domain_id, list(rows), keys_max=keys_max, kind_partitions=kind_partitions
204
+ )
205
+ return [
206
+ revision_event(
207
+ rows[ref.revision],
208
+ changes[ref.revision],
209
+ domain_id=domain_id,
210
+ branch=ref.branch,
211
+ schema=schemas.get(ref.branch),
212
+ sequence=ref.sequence,
213
+ )
214
+ for ref in refs
215
+ if ref.revision in rows
216
+ ]
217
+
218
+
219
+ async def branch_schema(
220
+ session: AsyncSession, domain_id: str, branch: str, *, writes: WriteSettings
221
+ ) -> Schema | None:
222
+ """Compiled schema of a branch head (none when the branch is not materialized)."""
223
+ row = await MaterializedRepository(session, domain_id, branch, writes=writes).schema()
224
+ return None if row is None else load_compiled(row.compiled)
225
+
226
+
227
+ _FIRST_PARENTS = text(
228
+ """
229
+ WITH RECURSIVE chain(id, generation, step) AS (
230
+ SELECT r.id, r.generation, 0 FROM revisions r
231
+ WHERE r.domain_id = :domain AND r.id = :head
232
+ UNION ALL
233
+ SELECT r.id, r.generation, chain.step + 1
234
+ FROM chain
235
+ JOIN revision_parents p
236
+ ON p.domain_id = :domain AND p.revision_id = chain.id AND p.position = 0
237
+ JOIN revisions r ON r.domain_id = :domain AND r.id = p.parent_id
238
+ WHERE chain.generation > :floor AND chain.step < :max_steps
239
+ )
240
+ SELECT id, generation FROM chain ORDER BY step
241
+ """
242
+ )
243
+
244
+
245
+ async def first_parents(
246
+ session: AsyncSession, domain_id: str, head: bytes, *, floor: int, max_steps: int
247
+ ) -> list[tuple[bytes, int]]:
248
+ """`head` and its first-parent ancestors, newest first, down to the first Revision at or
249
+ below generation `floor` (included) or `max_steps` steps back."""
250
+ result = await session.execute(
251
+ _FIRST_PARENTS,
252
+ {"domain": domain_id, "head": head, "floor": floor, "max_steps": max_steps},
253
+ )
254
+ return [(bytes(row.id), int(row.generation)) for row in result]
255
+
256
+
257
+ def matches(document: Mapping[str, Any], kinds: Iterable[str] | None) -> bool:
258
+ """Whether an event touches one of `kinds` (api names); every event when `kinds` is None."""
259
+ if kinds is None:
260
+ return True
261
+ return not set(document["kinds"]).isdisjoint(kinds)
@@ -0,0 +1,42 @@
1
+ """Commit pipeline (design 6.6): validated, atomic writes of data and schema changes."""
2
+
3
+ from lorebase_store.commit.changes import (
4
+ Change,
5
+ CommitResult,
6
+ SchemaChange,
7
+ SchemaDelete,
8
+ SchemaElementModel,
9
+ SchemaUpsert,
10
+ )
11
+ from lorebase_store.commit.errors import (
12
+ BranchLockTimeoutError,
13
+ BranchMaterializingError,
14
+ BranchNotFoundError,
15
+ BranchNotMaterializedError,
16
+ CommitError,
17
+ SchemaChangeError,
18
+ SensitivityLoweredError,
19
+ StaleHeadError,
20
+ ValidationFailedError,
21
+ )
22
+ from lorebase_store.commit.pipeline import commit, create_domain
23
+
24
+ __all__ = [
25
+ "BranchLockTimeoutError",
26
+ "BranchMaterializingError",
27
+ "BranchNotFoundError",
28
+ "BranchNotMaterializedError",
29
+ "Change",
30
+ "CommitError",
31
+ "CommitResult",
32
+ "SchemaChange",
33
+ "SchemaChangeError",
34
+ "SchemaDelete",
35
+ "SchemaElementModel",
36
+ "SchemaUpsert",
37
+ "SensitivityLoweredError",
38
+ "StaleHeadError",
39
+ "ValidationFailedError",
40
+ "commit",
41
+ "create_domain",
42
+ ]
@@ -0,0 +1,67 @@
1
+ """Inputs and outcome of a commit: data changes with provenance, schema changes, result."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+
9
+ from lorebase_core.model import (
10
+ Derivation,
11
+ Domain,
12
+ Kind,
13
+ Operation,
14
+ Relation,
15
+ Source,
16
+ Trait,
17
+ )
18
+ from lorebase_core.revision import ChangesetEntry
19
+ from lorebase_core.validate import DataChange
20
+
21
+ SchemaElementModel = Domain | Kind | Relation | Trait | Operation | Derivation | Source
22
+ """Anything stored in the `schema` partition."""
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class Change:
27
+ """A data change and the provenance its changed Attributes are attributed to.
28
+
29
+ `provenance` is a provenance entry id for every changed Attribute, or a map from
30
+ Attribute id to entry id (Attributes left out fall back to the commit's own entry).
31
+ `None` attributes the change to the commit's own entry, built from its origin and author.
32
+ """
33
+
34
+ change: DataChange
35
+ provenance: str | Mapping[str, str] | None = None
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class SchemaUpsert:
40
+ """Create or replace a schema element (the Domain element included)."""
41
+
42
+ element: SchemaElementModel
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class SchemaDelete:
47
+ """Remove a schema element by id; the Domain element cannot be removed."""
48
+
49
+ element_id: str
50
+
51
+
52
+ SchemaChange = SchemaUpsert | SchemaDelete
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class CommitResult:
57
+ """Outcome of a commit. `no_op` commits change nothing and leave the head in place."""
58
+
59
+ revision_id: bytes
60
+ parents: tuple[bytes, ...]
61
+ root: bytes
62
+ generation: int
63
+ ref_version: int
64
+ created_at: datetime
65
+ changeset: tuple[ChangesetEntry, ...]
66
+ derived_changes: tuple[DataChange, ...] = ()
67
+ no_op: bool = False
@@ -0,0 +1,60 @@
1
+ """Typed failures of the commit pipeline. Any failure leaves nothing visible."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Sequence
6
+
7
+ from lorebase_core.validate import Issue
8
+
9
+
10
+ class CommitError(Exception):
11
+ """Base class of commit failures."""
12
+
13
+
14
+ class BranchNotFoundError(CommitError):
15
+ """The branch does not exist (or the ref is a tag, which never moves)."""
16
+
17
+
18
+ class BranchNotMaterializedError(CommitError):
19
+ """The branch has no materialized head to validate against."""
20
+
21
+
22
+ class BranchMaterializingError(CommitError):
23
+ """The branch is still being copied in the background, and the change (a schema change)
24
+ needs its full copy: retry once its materialization is done."""
25
+
26
+
27
+ class StaleHeadError(CommitError):
28
+ """The branch head is not the one the caller expected (or moved during the commit)."""
29
+
30
+ def __init__(self, branch: str, expected: bytes, actual: bytes | None) -> None:
31
+ actual_text = "unknown" if actual is None else actual.hex()
32
+ super().__init__(f"branch {branch!r} is at {actual_text}, expected {expected.hex()}")
33
+ self.branch = branch
34
+ self.expected = expected
35
+ self.actual = actual
36
+
37
+
38
+ class BranchLockTimeoutError(CommitError):
39
+ """Another writer held the branch lock longer than the configured timeout."""
40
+
41
+
42
+ class SchemaChangeError(CommitError):
43
+ """A schema change cannot be applied (unknown element, Domain removal, wrong Domain)."""
44
+
45
+
46
+ class SensitivityLoweredError(CommitError):
47
+ """The change removes sensitivity tags (or sensitive Attributes) without having been
48
+ allowed to: only callers cleared for the tags may (design 10.2)."""
49
+
50
+ def __init__(self, tags: Iterable[str]) -> None:
51
+ self.tags = sorted(tags)
52
+ super().__init__(f"the change removes the sensitivity tags {', '.join(self.tags)}")
53
+
54
+
55
+ class ValidationFailedError(CommitError):
56
+ """The changes violate the schema or the existing data; nothing was written."""
57
+
58
+ def __init__(self, issues: Sequence[Issue]) -> None:
59
+ super().__init__(f"{len(issues)} validation issue(s); first: {issues[0].message}")
60
+ self.issues = list(issues)