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,652 @@
1
+ """Merge service: the core merge over the store, merge attempts and merge commits (design 6.8).
2
+
3
+ `MergeService` runs `lorebase_core.merge` for a Proposal over the Domain's tree nodes, objects
4
+ and Revision graph, with the materialized head of the target branch as index view and the
5
+ source branch's materialized Lineage as provenance. Every run is stored as a merge attempt
6
+ with its Conflicts (the previous open attempt of the Proposal becomes `stale`); resolutions
7
+ are read from `conflict_resolutions` and matched by fingerprint, so an attempt recomputed
8
+ against moved heads keeps the resolutions whose Conflicts did not change.
9
+
10
+ `merge` runs under the target branch lock and, when no Conflict remains, writes the outcome
11
+ like the commit pipeline (design 6.6): objects, nodes, the merge Revision with parents
12
+ `[target head, source head]`, changeset and provenance, materialization of the changeset on the
13
+ target branch, compare-and-swap of the ref, audit entry and outbox events (`revision.committed`
14
+ and the Operation events deferred on the source branch, `repositories.deferred_events`). A
15
+ fast-forward moves the ref to the source head and materializes the difference; an up-to-date
16
+ merge writes no Revision. Everything runs in the caller's transaction.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Awaitable, Callable, Mapping
22
+ from dataclasses import dataclass, field
23
+ from datetime import datetime
24
+ from typing import Any
25
+
26
+ from sqlalchemy.ext.asyncio import AsyncSession
27
+ from sqlalchemy.orm import Session
28
+
29
+ from lorebase_core.canonical import hex
30
+ from lorebase_core.expr.sensitivity import lowered_tags
31
+ from lorebase_core.merge import (
32
+ MergedTree,
33
+ MergeInput,
34
+ MergePolicy,
35
+ MergeResult,
36
+ Resolution,
37
+ tree_schema_loader,
38
+ )
39
+ from lorebase_core.merge.objects import load_object
40
+ from lorebase_core.model import (
41
+ IdPrefix,
42
+ MergeResolutionOrigin,
43
+ ProvenanceEntry,
44
+ Schema,
45
+ new_id,
46
+ new_ulid,
47
+ )
48
+ from lorebase_core.revision import Author, ChangesetEntry, Origin, Revision
49
+ from lorebase_core.tree import PARTITION_SCHEMA, Tree, TreeParams
50
+ from lorebase_store.commit.errors import (
51
+ BranchLockTimeoutError,
52
+ BranchNotFoundError,
53
+ SensitivityLoweredError,
54
+ StaleHeadError,
55
+ )
56
+ from lorebase_store.commit.pipeline import (
57
+ COMMITTED_EVENT,
58
+ _Prepared,
59
+ _record_events,
60
+ _store_revision,
61
+ utc_now,
62
+ )
63
+ from lorebase_store.commit.schema import load_compiled
64
+ from lorebase_store.materialize.apply import MaterializedChange, apply_changeset
65
+ from lorebase_store.materialize.background import delete_branch
66
+ from lorebase_store.materialize.stale import queue_after_tagging
67
+ from lorebase_store.merging.attempts import (
68
+ AttemptComputation,
69
+ ConflictState,
70
+ complete_provenance,
71
+ compute_attempt,
72
+ decisions_of,
73
+ taken_provenance,
74
+ )
75
+ from lorebase_store.merging.lineage import BranchLineage, MergeLineage
76
+ from lorebase_store.models.enums import MergeAttemptStatus, ProposalStatus, RefKind
77
+ from lorebase_store.nodestore import PgNodeStore
78
+ from lorebase_store.objectstore import EncodedObject, SessionObjectStore
79
+ from lorebase_store.repositories.deferred_events import DeferredEventRepository
80
+ from lorebase_store.repositories.domains import DomainRepository
81
+ from lorebase_store.repositories.events import OutboxRepository
82
+ from lorebase_store.repositories.index_view import MaterializedIndexView
83
+ from lorebase_store.repositories.materialized import MERGE_ORIGIN_TYPE, MaterializedRepository
84
+ from lorebase_store.repositories.merges import (
85
+ ConflictRepository,
86
+ ConflictRow,
87
+ MergeAttemptRepository,
88
+ MergeAttemptRow,
89
+ ResolutionRepository,
90
+ ResolutionRow,
91
+ )
92
+ from lorebase_store.repositories.proposals import ProposalRepository, ProposalRow
93
+ from lorebase_store.repositories.provenance import ProvenanceRow
94
+ from lorebase_store.repositories.refs import (
95
+ LockTimeoutError,
96
+ RefMovedError,
97
+ RefRepository,
98
+ RefRow,
99
+ )
100
+ from lorebase_store.repositories.revisions import PgRevisionGraph, RevisionRow
101
+ from lorebase_store.repositories.scope import scope_to_domain
102
+ from lorebase_store.runtime import StoreRuntime
103
+ from lorebase_store.telemetry import MERGE_ATTEMPTS
104
+
105
+ MERGE_ORIGIN = MERGE_ORIGIN_TYPE
106
+ """Origin type of merge Revisions and of the merge's own provenance entry."""
107
+ MERGE_REASON = "proposal.merge"
108
+ """Ref log reason of a merge."""
109
+ ATTEMPT_PREFIX = "mga"
110
+ CONFLICTS_RESULT = "conflicts"
111
+ REJECTED_DETAIL = (
112
+ "The merge reported this Conflict again after applying its resolution; choose another."
113
+ )
114
+
115
+ Clock = Callable[[], datetime]
116
+
117
+
118
+ class ProposalBranchMissingError(BranchNotFoundError):
119
+ """The source or target branch of a Proposal no longer exists."""
120
+
121
+ def __init__(self, side: str, name: str) -> None:
122
+ super().__init__(f"the {side} branch {name!r} of the Proposal does not exist")
123
+ self.side = side
124
+ self.name = name
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class MergeRequest:
129
+ """What to merge and how: the Proposal, who runs it and the merge policy."""
130
+
131
+ proposal: ProposalRow
132
+ actor: str
133
+ policy: MergePolicy = field(default_factory=MergePolicy)
134
+ message: str | None = None
135
+ idempotency_key: str | None = None
136
+ #: Delete the source branch after a successful merge (auto-close).
137
+ delete_source: bool = False
138
+ #: Called with the target's schema and the merged schema before a clean merge commits;
139
+ #: raises to refuse it (the caller's checks: removed sensitivity tags, Sources feeding
140
+ #: vault Attributes). Without it merges that remove sensitivity tags are refused
141
+ #: (`SensitivityLoweredError`).
142
+ schema_guard: Callable[[Schema, Schema], Awaitable[None]] | None = None
143
+
144
+
145
+ @dataclass(frozen=True)
146
+ class AttemptResult:
147
+ """The current attempt of a Proposal.
148
+
149
+ `computation` is set when this call ran the merge (otherwise the stored attempt was still
150
+ current); `previous` is the attempt it superseded. After a successful `merge`, `head` is
151
+ the new head of the target branch.
152
+ """
153
+
154
+ attempt: MergeAttemptRow
155
+ computation: AttemptComputation | None = None
156
+ previous: MergeAttemptRow | None = None
157
+ head: bytes | None = None
158
+ source_deleted: bool = False
159
+ events: tuple[int, ...] = ()
160
+ """Outbox ids of the deferred Operation events this merge wrote (`release`)."""
161
+
162
+ @property
163
+ def merged(self) -> bool:
164
+ return self.attempt.status is MergeAttemptStatus.MERGED
165
+
166
+ @property
167
+ def stale_previous(self) -> bool:
168
+ """Whether a head moved since the previous attempt."""
169
+ previous = self.previous
170
+ return previous is not None and (
171
+ previous.ours != self.attempt.ours or previous.theirs != self.attempt.theirs
172
+ )
173
+
174
+
175
+ @dataclass
176
+ class _Commit:
177
+ merged: MergedTree
178
+ changeset: list[ChangesetEntry]
179
+ bodies: dict[tuple[str, str], Mapping[str, Any] | None]
180
+ schema: Schema
181
+ schema_hash: bytes | None
182
+ refresh_kinds: list[str]
183
+ provenance: list[ProvenanceRow]
184
+
185
+
186
+ @dataclass
187
+ class _Computed:
188
+ computation: AttemptComputation
189
+ nodes: dict[bytes, bytes]
190
+ objects: list[EncodedObject]
191
+ commit: _Commit | None
192
+
193
+
194
+ class MergeService:
195
+ """Merge attempts and merge commits of one Domain, in the caller's transaction."""
196
+
197
+ def __init__(
198
+ self,
199
+ session: AsyncSession,
200
+ domain_id: str,
201
+ *,
202
+ runtime: StoreRuntime,
203
+ clock: Clock = utc_now,
204
+ retention: int = 5,
205
+ ) -> None:
206
+ self._session = session
207
+ self._domain = domain_id
208
+ self._runtime = runtime
209
+ self._clock = clock
210
+ self._retention = retention
211
+
212
+ async def current(self, request: MergeRequest) -> AttemptResult:
213
+ """The latest attempt, recomputed when there is none or a head moved since."""
214
+ await scope_to_domain(self._session, self._domain)
215
+ refs = RefRepository(self._session, self._domain)
216
+ target = await _branch(refs, request.proposal.target_ref, "target")
217
+ source = await _branch(refs, request.proposal.source_ref, "source")
218
+ latest = await MergeAttemptRepository(self._session, self._domain).latest(
219
+ request.proposal.id
220
+ )
221
+ if (
222
+ latest is not None
223
+ and latest.status in (MergeAttemptStatus.CONFLICTED, MergeAttemptStatus.RESOLVED)
224
+ and latest.ours == target.head
225
+ and latest.theirs == source.head
226
+ ):
227
+ return AttemptResult(attempt=latest)
228
+ return await self._run(request, target, source, commit=False)
229
+
230
+ async def recompute(self, request: MergeRequest) -> AttemptResult:
231
+ """Run the merge with the stored resolutions and store the attempt (no commit)."""
232
+ await scope_to_domain(self._session, self._domain)
233
+ refs = RefRepository(self._session, self._domain)
234
+ target = await _branch(refs, request.proposal.target_ref, "target")
235
+ source = await _branch(refs, request.proposal.source_ref, "source")
236
+ return await self._run(request, target, source, commit=False)
237
+
238
+ async def merge(self, request: MergeRequest) -> AttemptResult:
239
+ """Merge under the target branch lock: commit when clean, else store the attempt.
240
+
241
+ Raises `BranchLockTimeoutError`, `ProposalBranchMissingError` or `StaleHeadError`.
242
+ """
243
+ await scope_to_domain(self._session, self._domain)
244
+ refs = RefRepository(self._session, self._domain)
245
+ timeout = self._runtime.settings.commit.lock_timeout_ms
246
+ try:
247
+ await refs.lock(request.proposal.target_ref, timeout_ms=timeout)
248
+ except LockTimeoutError as error:
249
+ raise BranchLockTimeoutError(str(error)) from error
250
+ target = await _branch(refs, request.proposal.target_ref, "target")
251
+ source = await _branch(refs, request.proposal.source_ref, "source")
252
+ result = await self._run(request, target, source, commit=True)
253
+ attempt = result.attempt
254
+ clean = attempt.status is not MergeAttemptStatus.CONFLICTED
255
+ MERGE_ATTEMPTS.add(
256
+ 1, {"result": attempt.result if clean and attempt.result else CONFLICTS_RESULT}
257
+ )
258
+ return result
259
+
260
+ async def abandon(self, proposal_id: str) -> int:
261
+ """Mark the open attempts of a closed Proposal `abandoned`."""
262
+ await scope_to_domain(self._session, self._domain)
263
+ return await MergeAttemptRepository(self._session, self._domain).supersede(
264
+ proposal_id, status=MergeAttemptStatus.ABANDONED
265
+ )
266
+
267
+ # Run ------------------------------------------------------------------------------
268
+
269
+ async def _run(
270
+ self, request: MergeRequest, target: RefRow, source: RefRow, *, commit: bool
271
+ ) -> AttemptResult:
272
+ session, domain_id = self._session, self._domain
273
+ settings = self._runtime.settings
274
+ domain = await DomainRepository(session).require(domain_id)
275
+ stored = await ResolutionRepository(session, domain_id).of(request.proposal.id)
276
+ resolutions = [_resolution(row) for row in stored]
277
+ created_at = self._clock()
278
+ attempt_id = f"{ATTEMPT_PREFIX}_{new_ulid()}"
279
+ runtime = self._runtime
280
+
281
+ def compute(sync: Session) -> _Computed:
282
+ store = PgNodeStore(
283
+ sync,
284
+ domain_id,
285
+ cache=runtime.node_cache,
286
+ prefetch_batch=settings.nodes.prefetch_batch,
287
+ )
288
+ objects = SessionObjectStore(sync, domain_id, batch=settings.nodes.prefetch_batch)
289
+ graph = PgRevisionGraph(sync, domain_id)
290
+ index = MaterializedIndexView(
291
+ sync, domain_id, target.name, batch=settings.commit.prefetch_batch
292
+ )
293
+ merge_input = MergeInput(
294
+ store=store,
295
+ objects=objects,
296
+ graph=graph,
297
+ revision_root=graph.root,
298
+ ours=target.head,
299
+ theirs=source.head,
300
+ index_view=lambda _head: index,
301
+ provenance=BranchLineage(sync, domain_id, source.name),
302
+ lineage=MergeLineage(
303
+ sync, domain_id, {target.head: target.name, source.head: source.name}
304
+ ),
305
+ policy=request.policy,
306
+ tree_params=TreeParams(domain.leaf_size),
307
+ schema_rules=runtime.schema.rules(),
308
+ )
309
+ computation = compute_attempt(merge_input, resolutions)
310
+ prepared = None
311
+ outcome = computation.outcome
312
+ if (
313
+ commit
314
+ and isinstance(outcome, MergedTree)
315
+ and outcome.result is not MergeResult.UP_TO_DATE
316
+ ):
317
+ prepared = _prepare_commit(
318
+ store, objects, computation, outcome, request, created_at
319
+ )
320
+ return _Computed(computation, store.take_pending(), objects.take_pending(), prepared)
321
+
322
+ computed = await session.run_sync(compute)
323
+ computation = computed.computation
324
+ head: bytes | None = None
325
+ source_deleted = False
326
+ events: list[int] = []
327
+ if commit and computation.clean:
328
+ head = await self._commit(request, target, source, computed, created_at)
329
+ events = await DeferredEventRepository(session, domain_id).release(
330
+ source.name,
331
+ source.head,
332
+ {
333
+ "branch": target.name,
334
+ "head": hex(head),
335
+ "source_branch": source.name,
336
+ "proposal": request.proposal.id,
337
+ },
338
+ )
339
+ if request.delete_source and source.name != target.name:
340
+ # A large source (promoted to a full copy) is hidden now and its rows are
341
+ # deleted by a background job.
342
+ try:
343
+ await delete_branch(
344
+ session, domain_id, source.name, runtime=self._runtime, clock=self._clock
345
+ )
346
+ except LockTimeoutError as error:
347
+ raise BranchLockTimeoutError(str(error)) from error
348
+ source_deleted = True
349
+ status = (
350
+ MergeAttemptStatus.MERGED
351
+ if head is not None
352
+ else MergeAttemptStatus.RESOLVED
353
+ if computation.clean
354
+ else MergeAttemptStatus.CONFLICTED
355
+ )
356
+ attempts = MergeAttemptRepository(session, domain_id)
357
+ previous = await attempts.latest(request.proposal.id)
358
+ await attempts.supersede(request.proposal.id, status=MergeAttemptStatus.STALE)
359
+ outcome = computation.outcome
360
+ attempt = MergeAttemptRow(
361
+ id=attempt_id,
362
+ proposal_id=request.proposal.id,
363
+ base=None if outcome.virtual_base else outcome.base,
364
+ ours=target.head,
365
+ theirs=source.head,
366
+ status=status,
367
+ created_at=created_at,
368
+ virtual_base=outcome.virtual_base,
369
+ result=outcome.result.value if isinstance(outcome, MergedTree) else CONFLICTS_RESULT,
370
+ policy=request.policy.model_dump(mode="json", exclude_defaults=True),
371
+ created_by=request.actor,
372
+ outcome=_summary(computation),
373
+ revision=head,
374
+ idempotency_key=request.idempotency_key if commit else None,
375
+ )
376
+ await attempts.insert(attempt)
377
+ by_fingerprint = {row.fingerprint: row for row in stored}
378
+ await ConflictRepository(session, domain_id).insert_many(
379
+ attempt_id, [_conflict_row(state, by_fingerprint) for state in computation.conflicts]
380
+ )
381
+ await attempts.prune(request.proposal.id, keep=self._retention)
382
+ changes: dict[str, Any] = {"checks": _checks(request.proposal, attempt, computation)}
383
+ if head is not None:
384
+ changes.update(status=ProposalStatus.MERGED, merged_revision=head)
385
+ await ProposalRepository(session, domain_id).update(
386
+ request.proposal.id, at=created_at, **changes
387
+ )
388
+ return AttemptResult(
389
+ attempt=attempt,
390
+ computation=computation,
391
+ previous=previous,
392
+ head=head,
393
+ source_deleted=source_deleted,
394
+ events=tuple(events),
395
+ )
396
+
397
+ async def _commit(
398
+ self,
399
+ request: MergeRequest,
400
+ target: RefRow,
401
+ source: RefRow,
402
+ computed: _Computed,
403
+ created_at: datetime,
404
+ ) -> bytes:
405
+ """Write a clean outcome to the target branch; returns its new head."""
406
+ prepared = computed.commit
407
+ if prepared is None: # up to date: the target already contains the source
408
+ return target.head
409
+ session, domain_id = self._session, self._domain
410
+ settings = self._runtime.settings
411
+ compiled = await MaterializedRepository(
412
+ session, domain_id, target.name, writes=settings.writes
413
+ ).schema()
414
+ target_schema: Schema | None = None
415
+ if compiled is not None:
416
+ target_schema = load_compiled(compiled.compiled)
417
+ if request.schema_guard is not None:
418
+ await request.schema_guard(target_schema, prepared.schema)
419
+ else:
420
+ # Fails closed: only a caller-supplied guard (which checks clearance) may
421
+ # let a merge remove sensitivity tags.
422
+ lowered = lowered_tags(target_schema, prepared.schema)
423
+ if lowered:
424
+ raise SensitivityLoweredError(t.value for t in lowered)
425
+ merged = prepared.merged
426
+ changes = [
427
+ MaterializedChange(entry, prepared.bodies[(entry.partition, entry.key)])
428
+ for entry in prepared.changeset
429
+ ]
430
+ message = request.message or _message(request.proposal)
431
+ stored: RevisionRow | None = None
432
+ if merged.result is MergeResult.FAST_FORWARD:
433
+ new_head, at = source.head, created_at
434
+ else:
435
+ revision = Revision(
436
+ parents=(target.head, source.head),
437
+ root=merged.root,
438
+ domain=domain_id,
439
+ author=Author(principal=request.actor),
440
+ message=message,
441
+ origin=Origin(type=MERGE_ORIGIN, ref=request.proposal.id),
442
+ created_at=created_at,
443
+ stats=merged.stats,
444
+ )
445
+ stored = await _store_revision(
446
+ session,
447
+ self._runtime,
448
+ revision,
449
+ _Prepared(
450
+ root=merged.root,
451
+ changeset=prepared.changeset,
452
+ bodies=prepared.bodies,
453
+ objects=computed.objects,
454
+ nodes=computed.nodes,
455
+ schema=prepared.schema,
456
+ schema_hash=prepared.schema_hash,
457
+ ),
458
+ prepared.provenance,
459
+ )
460
+ new_head, at = stored.id, stored.created_at
461
+ await apply_changeset(
462
+ session,
463
+ domain_id,
464
+ target.name,
465
+ revision_id=new_head,
466
+ at=at,
467
+ changes=changes,
468
+ schema=prepared.schema,
469
+ schema_hash=prepared.schema_hash,
470
+ refresh_kinds=prepared.refresh_kinds,
471
+ settings=settings,
472
+ expression_limits=self._runtime.schema.expression_limits,
473
+ runtime=self._runtime,
474
+ lineage_source=source.name if merged.result is MergeResult.FAST_FORWARD else None,
475
+ fast_forward_from=target.head if merged.result is MergeResult.FAST_FORWARD else None,
476
+ )
477
+ if target_schema is not None and prepared.schema_hash is not None:
478
+ await queue_after_tagging(
479
+ session,
480
+ domain_id,
481
+ target.name,
482
+ target_schema,
483
+ prepared.schema,
484
+ runtime=self._runtime,
485
+ )
486
+ refs = RefRepository(session, domain_id)
487
+ try:
488
+ await refs.compare_and_swap(
489
+ target.name,
490
+ expected_head=target.head,
491
+ expected_version=target.version,
492
+ new_head=new_head,
493
+ actor=request.actor,
494
+ at=created_at,
495
+ reason=MERGE_REASON,
496
+ )
497
+ except RefMovedError as error:
498
+ current = await refs.get(target.name)
499
+ raise StaleHeadError(
500
+ target.name, target.head, None if current is None else current.head
501
+ ) from error
502
+ if stored is None:
503
+ await OutboxRepository(session).enqueue(
504
+ domain_id,
505
+ COMMITTED_EVENT,
506
+ {
507
+ "domain": domain_id,
508
+ "branch": target.name,
509
+ "revision": hex(new_head),
510
+ "fast_forward": True,
511
+ "changes": merged.stats.changes,
512
+ "message": message,
513
+ },
514
+ )
515
+ else:
516
+ await _record_events(
517
+ session, domain_id, target.name, stored, prepared.changeset, message
518
+ )
519
+ return new_head
520
+
521
+
522
+ async def _branch(refs: RefRepository, name: str, side: str) -> RefRow:
523
+ ref = await refs.get(name)
524
+ if ref is None or ref.kind is not RefKind.BRANCH:
525
+ raise ProposalBranchMissingError(side, name)
526
+ return ref
527
+
528
+
529
+ def _resolution(row: ResolutionRow) -> Resolution:
530
+ return Resolution.model_validate(
531
+ {**row.resolution, "fingerprint": row.fingerprint, "resolver": row.resolved_by}
532
+ )
533
+
534
+
535
+ def _message(proposal: ProposalRow) -> str:
536
+ return f"Merge {proposal.source_ref} into {proposal.target_ref}: {proposal.title}"
537
+
538
+
539
+ def _prepare_commit(
540
+ store: PgNodeStore,
541
+ objects: SessionObjectStore,
542
+ computation: AttemptComputation,
543
+ merged: MergedTree,
544
+ request: MergeRequest,
545
+ created_at: datetime,
546
+ ) -> _Commit:
547
+ """Changeset with provenance, object bodies and schema of a clean outcome."""
548
+ provenance: list[ProvenanceRow] = []
549
+ changeset = list(merged.changes)
550
+ if merged.result is not MergeResult.MERGED:
551
+ changeset = taken_provenance(changeset, objects)
552
+ else:
553
+ own = ProvenanceRow(
554
+ id=new_id(IdPrefix.PROVENANCE),
555
+ origin_type=MERGE_ORIGIN,
556
+ origin_ref=Origin(type=MERGE_ORIGIN, ref=request.proposal.id).model_dump(),
557
+ actor=request.actor,
558
+ at=created_at,
559
+ )
560
+ decisions = decisions_of(computation, merged.auto_resolutions)
561
+ entries = {
562
+ d.fingerprint: ProvenanceEntry(
563
+ id=new_id(IdPrefix.PROVENANCE),
564
+ origin=MergeResolutionOrigin(
565
+ proposal_id=request.proposal.id,
566
+ conflict_id=d.fingerprint,
567
+ resolver=d.resolver or request.actor,
568
+ ),
569
+ actor=d.resolver if d.resolver and ":" not in d.resolver else request.actor,
570
+ at=created_at,
571
+ )
572
+ for d in decisions
573
+ }
574
+ changeset, used = complete_provenance(
575
+ changeset, objects, decisions, {fp: e.id for fp, e in entries.items()}, own.id
576
+ )
577
+ provenance = [own, *(ProvenanceRow.from_entry(entries[fp]) for fp in sorted(used))]
578
+ bodies: dict[tuple[str, str], Mapping[str, Any] | None] = {
579
+ (entry.partition, entry.key): None
580
+ if entry.new_hash is None
581
+ else load_object(objects, entry.new_hash)
582
+ for entry in changeset
583
+ }
584
+ schema = tree_schema_loader(store, objects)(merged.root)
585
+ schema_keys = [entry.key for entry in changeset if entry.partition == PARTITION_SCHEMA]
586
+ schema_hash = (
587
+ Tree.load(store, merged.root).partitions[PARTITION_SCHEMA].root if schema_keys else None
588
+ )
589
+ return _Commit(
590
+ merged=merged,
591
+ changeset=changeset,
592
+ bodies=bodies,
593
+ schema=schema,
594
+ schema_hash=schema_hash,
595
+ refresh_kinds=sorted(key for key in schema_keys if key in schema.kinds),
596
+ provenance=provenance,
597
+ )
598
+
599
+
600
+ def _summary(computation: AttemptComputation) -> dict[str, Any]:
601
+ outcome = computation.outcome
602
+ summary: dict[str, Any] = {
603
+ "open": computation.open_count,
604
+ "resolved": computation.resolved_count,
605
+ "auto_resolutions": [a.model_dump(mode="json") for a in outcome.auto_resolutions],
606
+ "unmatched_resolutions": list(computation.unmatched),
607
+ }
608
+ if isinstance(outcome, MergedTree):
609
+ summary["stats"] = outcome.stats.model_dump()
610
+ return summary
611
+
612
+
613
+ def _conflict_row(state: ConflictState, stored: Mapping[str, ResolutionRow]) -> ConflictRow:
614
+ conflict = state.conflict
615
+ payload = conflict.model_dump(mode="json")
616
+ payload["members"] = list(state.members)
617
+ if state.rejected:
618
+ payload["details"] = {**payload.get("details", {}), "rejected": REJECTED_DETAIL}
619
+ row = stored.get(conflict.fingerprint)
620
+ resolved = not state.open and state.resolution is not None
621
+ return ConflictRow(
622
+ fingerprint=conflict.fingerprint,
623
+ code=conflict.kind.value,
624
+ partition=conflict.partition,
625
+ element_key=conflict.key,
626
+ attribute_id=conflict.attribute,
627
+ payload=payload,
628
+ member_count=conflict.count,
629
+ resolution=state.resolution.model_dump(mode="json")
630
+ if resolved and state.resolution
631
+ else None,
632
+ resolved_by=row.resolved_by if resolved and row else None,
633
+ resolved_at=row.at if resolved and row else None,
634
+ )
635
+
636
+
637
+ def _checks(
638
+ proposal: ProposalRow, attempt: MergeAttemptRow, computation: AttemptComputation
639
+ ) -> dict[str, Any]:
640
+ """Proposal checks with the merge preview of the latest attempt (design 10.3)."""
641
+ checks = dict(proposal.checks or {})
642
+ checks["merge"] = {
643
+ "attempt": attempt.id,
644
+ "result": attempt.result,
645
+ "status": attempt.status.value,
646
+ "open_conflicts": computation.open_count,
647
+ "resolved_conflicts": computation.resolved_count,
648
+ "ours": hex(attempt.ours),
649
+ "theirs": hex(attempt.theirs),
650
+ "at": attempt.created_at.isoformat(),
651
+ }
652
+ return checks