lorebase-scribe 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 (41) hide show
  1. lorebase_scribe/__init__.py +3 -0
  2. lorebase_scribe/__main__.py +156 -0
  3. lorebase_scribe/checkpoints.py +324 -0
  4. lorebase_scribe/cron.py +117 -0
  5. lorebase_scribe/embeddings.py +570 -0
  6. lorebase_scribe/extraction/__init__.py +17 -0
  7. lorebase_scribe/extraction/assemble.py +339 -0
  8. lorebase_scribe/extraction/candidates.py +136 -0
  9. lorebase_scribe/extraction/discovery.py +567 -0
  10. lorebase_scribe/extraction/llm_discovery.py +271 -0
  11. lorebase_scribe/extraction/matching.py +285 -0
  12. lorebase_scribe/extraction/ontology.py +432 -0
  13. lorebase_scribe/extraction/parsing.py +114 -0
  14. lorebase_scribe/extraction/pipeline.py +1091 -0
  15. lorebase_scribe/extraction/relations.py +194 -0
  16. lorebase_scribe/extraction/sandbox.py +269 -0
  17. lorebase_scribe/extraction/settings.py +293 -0
  18. lorebase_scribe/extraction/text.py +179 -0
  19. lorebase_scribe/extraction/tier2.py +573 -0
  20. lorebase_scribe/extraction/values.py +282 -0
  21. lorebase_scribe/jobs.py +418 -0
  22. lorebase_scribe/scheduler.py +318 -0
  23. lorebase_scribe/service.py +146 -0
  24. lorebase_scribe/settings.py +521 -0
  25. lorebase_scribe/sources/__init__.py +60 -0
  26. lorebase_scribe/sources/access.py +241 -0
  27. lorebase_scribe/sources/connectors.py +320 -0
  28. lorebase_scribe/sources/delta.py +175 -0
  29. lorebase_scribe/sources/http_json.py +283 -0
  30. lorebase_scribe/sources/mapping.py +429 -0
  31. lorebase_scribe/sources/profile.py +36 -0
  32. lorebase_scribe/sources/records.py +298 -0
  33. lorebase_scribe/sources/sql.py +332 -0
  34. lorebase_scribe/sources/sync.py +730 -0
  35. lorebase_scribe/telemetry.py +113 -0
  36. lorebase_scribe/webhooks.py +99 -0
  37. lorebase_scribe/worker.py +254 -0
  38. lorebase_scribe-0.2.3.dist-info/METADATA +23 -0
  39. lorebase_scribe-0.2.3.dist-info/RECORD +41 -0
  40. lorebase_scribe-0.2.3.dist-info/WHEEL +4 -0
  41. lorebase_scribe-0.2.3.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,730 @@
1
+ """One synchronization run of a structured Source (design 9.1).
2
+
3
+ 1. Resolve the Source in the schema of the Domain's default branch (the target branch) and
4
+ record the run in `source_runs`.
5
+ 2. Read the rows (incremental Sources from the watermark of the last successful run) and map
6
+ them; rows that do not fit are skipped and reported, up to `max_row_errors`.
7
+ 3. Prepare the Source branch (`target_branch_policy.branch_template`, default
8
+ `source/{source}`): create it from the target branch, or, when no Proposal from it is open
9
+ and its head differs from the target's, recreate it at the target head, so every run
10
+ compares the Source with what the target holds now.
11
+ 4. Compute the delta against the branch head and commit it in batches of `commit_batch`
12
+ changes with origin `source_sync` and one provenance entry per changed row (Source id, run
13
+ id, row locator).
14
+ 5. Open the Source's Proposal into the target branch, or update the open one (title with the
15
+ run's counts, idempotent per run), then apply the merge policy: `proposal` computes the
16
+ merge preview only; `auto_when_clean` merges when no Conflict remains; and
17
+ `auto_with_authority` also resolves value Conflicts on Attributes whose `authority` is the
18
+ Source in the Source's favor.
19
+
20
+ A dry run reads, maps and computes the delta against the state step 3 would produce, then
21
+ records the counts without writing to any branch.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ import re
28
+ import time
29
+ from collections import defaultdict
30
+ from collections.abc import Callable, Mapping
31
+ from dataclasses import dataclass, field
32
+ from datetime import UTC, date, datetime
33
+ from decimal import Decimal
34
+ from typing import Any
35
+
36
+ from pydantic import JsonValue, ValidationError
37
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
38
+
39
+ from lorebase_core.merge import MergePolicy, Side
40
+ from lorebase_core.model import (
41
+ IdPrefix,
42
+ ProvenanceEntry,
43
+ Schema,
44
+ Source,
45
+ SourceSyncOrigin,
46
+ new_id,
47
+ new_ulid,
48
+ )
49
+ from lorebase_core.model.source import MergePolicy as SourcePolicy
50
+ from lorebase_core.model.source import SyncMode
51
+ from lorebase_core.revision import Author, Origin
52
+ from lorebase_core.types.encoding import format_timestamp
53
+ from lorebase_scribe.settings import SourceSettings
54
+ from lorebase_scribe.sources.connectors import ConnectorFactory, default_connector
55
+ from lorebase_scribe.sources.delta import (
56
+ CurrentState,
57
+ Delta,
58
+ StoredRecord,
59
+ batches,
60
+ compute_delta,
61
+ )
62
+ from lorebase_scribe.sources.mapping import (
63
+ MappedRow,
64
+ Row,
65
+ RowError,
66
+ RowMapper,
67
+ SourceDefinitionError,
68
+ )
69
+ from lorebase_scribe.telemetry import SOURCE_CHANGES, SOURCE_ROWS, tracer
70
+ from lorebase_store.commit import Change, ValidationFailedError, commit
71
+ from lorebase_store.commit.schema import load_compiled
72
+ from lorebase_store.materialize import create_branch
73
+ from lorebase_store.merging import AttemptResult, MergeRequest, MergeService
74
+ from lorebase_store.models.enums import ProposalOrigin, ProposalStatus, RunStatus
75
+ from lorebase_store.repositories import (
76
+ DomainRepository,
77
+ DomainRow,
78
+ MaterializedRepository,
79
+ ProposalFilter,
80
+ ProposalRepository,
81
+ ProposalRow,
82
+ RefRepository,
83
+ SourceRunRepository,
84
+ SourceRunRow,
85
+ scope_to_domain,
86
+ )
87
+ from lorebase_store.runtime import StoreRuntime
88
+
89
+ log = logging.getLogger("lorebase.scribe.sources")
90
+
91
+ RUN_PREFIX = "srn"
92
+ PROPOSAL_PREFIX = "prp"
93
+ POLICY_KEY = "merge_policy"
94
+ """Key of the merge policy in a Domain's settings and in a Proposal's `policies`."""
95
+ SYNC_ORIGIN = "source_sync"
96
+ ISSUE_SAMPLES = 10
97
+
98
+ Clock = Callable[[], datetime]
99
+
100
+
101
+ def utc_now() -> datetime:
102
+ return datetime.now(UTC)
103
+
104
+
105
+ class SourceNotFoundError(LookupError):
106
+ """The Domain or the Source does not exist."""
107
+
108
+
109
+ class SourceSyncError(RuntimeError):
110
+ """The run cannot succeed as is (bad rows, rejected changes); retrying will not help."""
111
+
112
+ def __init__(self, message: str, details: dict[str, Any] | None = None) -> None:
113
+ super().__init__(message)
114
+ self.details = details or {}
115
+
116
+
117
+ PERMANENT_ERRORS: tuple[type[Exception], ...] = (
118
+ SourceNotFoundError,
119
+ SourceDefinitionError,
120
+ SourceSyncError,
121
+ )
122
+ """Failures a retry of the same job cannot fix."""
123
+
124
+
125
+ # Watermarks -----------------------------------------------------------------------------------
126
+
127
+
128
+ _LEGACY_BYTES = re.compile(r"b'.*'|b\".*\"", re.S)
129
+
130
+
131
+ def encode_watermark(value: Any) -> dict[str, JsonValue]:
132
+ """A watermark value as JSON with its type, so the next run binds the same native type."""
133
+ if isinstance(value, bytes | bytearray | memoryview):
134
+ # SQL Server rowversion is binary(8): hex keeps it JSON-safe and bound back as bytes.
135
+ return {"type": "bytes", "value": bytes(value).hex()}
136
+ if isinstance(value, datetime):
137
+ return {"type": "timestamp", "value": value.isoformat()}
138
+ if isinstance(value, date):
139
+ return {"type": "date", "value": value.isoformat()}
140
+ if isinstance(value, bool):
141
+ return {"type": "text", "value": str(value).lower()}
142
+ if isinstance(value, int):
143
+ return {"type": "integer", "value": str(value)}
144
+ if isinstance(value, Decimal):
145
+ return {"type": "decimal", "value": str(value)}
146
+ if isinstance(value, float):
147
+ return {"type": "float", "value": repr(value)}
148
+ return {"type": "text", "value": str(value)}
149
+
150
+
151
+ def decode_watermark(stored: Any) -> Any:
152
+ if not isinstance(stored, dict) or "value" not in stored:
153
+ return None
154
+ kind, text = stored.get("type"), str(stored["value"])
155
+ if kind == "bytes":
156
+ return bytes.fromhex(text)
157
+ if kind == "text" and _LEGACY_BYTES.fullmatch(text):
158
+ return None # the repr of bytes stored by an older run: it cannot be bound, resync
159
+ if kind == "timestamp":
160
+ return datetime.fromisoformat(text)
161
+ if kind == "date":
162
+ return date.fromisoformat(text)
163
+ if kind == "integer":
164
+ return int(text)
165
+ if kind == "decimal":
166
+ return Decimal(text)
167
+ if kind == "float":
168
+ return float(text)
169
+ return text
170
+
171
+
172
+ def _later(current: Any, candidate: Any) -> Any:
173
+ candidate = bytes(candidate) if isinstance(candidate, bytearray | memoryview) else candidate
174
+ if current is None:
175
+ return candidate
176
+ try:
177
+ return candidate if candidate > current else current
178
+ except TypeError:
179
+ return candidate if str(candidate) > str(current) else current
180
+
181
+
182
+ # Report ---------------------------------------------------------------------------------------
183
+
184
+
185
+ @dataclass
186
+ class SyncReport:
187
+ """Outcome of a run, stored as the run's stats and returned as the job result."""
188
+
189
+ run_id: str
190
+ domain: str
191
+ source: str
192
+ source_name: str
193
+ branch: str
194
+ target: str
195
+ dry_run: bool
196
+ rows_read: int = 0
197
+ rows_mapped: int = 0
198
+ rows_failed: int = 0
199
+ counts: dict[str, int] = field(default_factory=dict)
200
+ revisions: list[str] = field(default_factory=list)
201
+ proposal_id: str | None = None
202
+ proposal_status: str | None = None
203
+ merge_status: str | None = None
204
+ conflicts: int = 0
205
+ errors: list[dict[str, JsonValue]] = field(default_factory=list)
206
+ watermark: dict[str, JsonValue] | None = None
207
+ duration_ms: int = 0
208
+
209
+ def as_dict(self) -> dict[str, Any]:
210
+ return {
211
+ "run": self.run_id,
212
+ "domain": self.domain,
213
+ "source": self.source,
214
+ "source_name": self.source_name,
215
+ "branch": self.branch,
216
+ "target": self.target,
217
+ "dry_run": self.dry_run,
218
+ "rows": {
219
+ "read": self.rows_read,
220
+ "mapped": self.rows_mapped,
221
+ "failed": self.rows_failed,
222
+ },
223
+ "counts": dict(self.counts),
224
+ "revisions": list(self.revisions),
225
+ "proposal": self.proposal_id,
226
+ "proposal_status": self.proposal_status,
227
+ "merge_status": self.merge_status,
228
+ "conflicts": self.conflicts,
229
+ "errors": list(self.errors),
230
+ "watermark": self.watermark,
231
+ "duration_ms": self.duration_ms,
232
+ }
233
+
234
+
235
+ @dataclass
236
+ class _Read:
237
+ rows: list[MappedRow] = field(default_factory=list)
238
+ watermark: Any = None
239
+
240
+
241
+ # Helpers --------------------------------------------------------------------------------------
242
+
243
+
244
+ def find_source(schema: Schema, reference: str) -> Source | None:
245
+ """A Source by id or api name."""
246
+ return schema.sources.get(reference) or schema.source_by_api_name(reference)
247
+
248
+
249
+ def source_branch(source: Source) -> str:
250
+ template = source.target_branch_policy.branch_template
251
+ try:
252
+ name = template.format(source=source.api_name)
253
+ except (KeyError, IndexError, ValueError):
254
+ raise SourceDefinitionError(f"invalid branch template {template!r}") from None
255
+ if not name.strip():
256
+ raise SourceDefinitionError(f"branch template {template!r} gives an empty name")
257
+ return name
258
+
259
+
260
+ def domain_merge_policy(domain: DomainRow) -> MergePolicy:
261
+ """The Domain's merge policy setting; the default policy when it is missing or invalid."""
262
+ raw = (domain.settings or {}).get(POLICY_KEY) or {}
263
+ try:
264
+ return MergePolicy.model_validate(raw)
265
+ except ValidationError:
266
+ log.warning("invalid merge policy setting", extra={"domain": domain.id})
267
+ return MergePolicy()
268
+
269
+
270
+ def merge_policy(domain: DomainRow, schema: Schema, source: Source) -> MergePolicy:
271
+ """The policy of the Source's merges: the Domain's, plus, for `auto_with_authority`, the
272
+ Source's side on every Attribute it is the authority of."""
273
+ policy = domain_merge_policy(domain)
274
+ if source.target_branch_policy.merge_policy is not SourcePolicy.AUTO_WITH_AUTHORITY:
275
+ return policy
276
+ kind = schema.kinds.get(source.target_kind)
277
+ authority = {
278
+ attribute.id: Side.THEIRS
279
+ for attribute in (kind.attributes.values() if kind else ())
280
+ if attribute.authority == source.id
281
+ }
282
+ return policy.model_copy(
283
+ update={"prefer_attributes": {**policy.prefer_attributes, **authority}}
284
+ )
285
+
286
+
287
+ def _issues(error: ValidationFailedError) -> list[dict[str, Any]]:
288
+ return [
289
+ {"code": issue.code.value, "message": issue.message, "key": issue.key}
290
+ for issue in error.issues[:ISSUE_SAMPLES]
291
+ ]
292
+
293
+
294
+ # The run ----------------------------------------------------------------------------------------
295
+
296
+
297
+ class SourceSync:
298
+ """Runs Source synchronizations against one database."""
299
+
300
+ def __init__(
301
+ self,
302
+ sessions: async_sessionmaker[AsyncSession],
303
+ runtime: StoreRuntime,
304
+ settings: SourceSettings,
305
+ *,
306
+ principal: str,
307
+ connectors: ConnectorFactory = default_connector,
308
+ clock: Clock = utc_now,
309
+ ) -> None:
310
+ self._sessions = sessions
311
+ self._runtime = runtime
312
+ self._settings = settings
313
+ self._principal = principal
314
+ self._connectors = connectors
315
+ self._clock = clock
316
+
317
+ async def run(self, domain_id: str, reference: str, *, dry_run: bool = False) -> SyncReport:
318
+ """Synchronize the Source `reference` (id or api name) of a Domain."""
319
+ started = time.monotonic()
320
+ async with self._sessions() as session, session.begin():
321
+ await scope_to_domain(session, domain_id)
322
+ domain = await DomainRepository(session).get(domain_id)
323
+ if domain is None:
324
+ raise SourceNotFoundError(f"no Domain {domain_id!r}")
325
+ schema = await self._schema(session, domain_id, domain.default_branch)
326
+ source = find_source(schema, reference)
327
+ if source is None:
328
+ raise SourceNotFoundError(f"no Source {reference!r} in Domain {domain_id!r}")
329
+ report = SyncReport(
330
+ run_id=f"{RUN_PREFIX}_{new_ulid()}",
331
+ domain=domain_id,
332
+ source=source.id,
333
+ source_name=source.api_name,
334
+ branch=source_branch(source),
335
+ target=domain.default_branch,
336
+ dry_run=dry_run,
337
+ )
338
+ await SourceRunRepository(session, domain_id).insert(
339
+ SourceRunRow(
340
+ id=report.run_id,
341
+ source_id=source.id,
342
+ status=RunStatus.RUNNING,
343
+ started_at=self._clock(),
344
+ finished_at=None,
345
+ stats={"dry_run": dry_run},
346
+ )
347
+ )
348
+ with tracer.start_as_current_span(
349
+ "scribe.source_sync",
350
+ attributes={
351
+ "lorebase.domain": domain_id,
352
+ "lorebase.source": source.api_name,
353
+ "lorebase.run": report.run_id,
354
+ "lorebase.dry_run": dry_run,
355
+ },
356
+ ) as span:
357
+ try:
358
+ await self._run(domain, schema, source, report)
359
+ except Exception as error:
360
+ report.duration_ms = int((time.monotonic() - started) * 1000)
361
+ details = error.details if isinstance(error, SourceSyncError) else {}
362
+ await self._finish(
363
+ report,
364
+ RunStatus.FAILED,
365
+ error={"type": type(error).__name__, "message": str(error), **details},
366
+ )
367
+ span.record_exception(error)
368
+ raise
369
+ report.duration_ms = int((time.monotonic() - started) * 1000)
370
+ await self._finish(report, RunStatus.SUCCEEDED)
371
+ span.set_attribute("lorebase.changes", sum(report.counts.values()))
372
+ log.info(
373
+ "source synchronized",
374
+ extra={
375
+ "domain": domain_id,
376
+ "source": source.api_name,
377
+ "run": report.run_id,
378
+ "dry_run": dry_run,
379
+ "rows": report.rows_read,
380
+ **report.counts,
381
+ "proposal": report.proposal_id,
382
+ "merge_status": report.merge_status,
383
+ "duration_ms": report.duration_ms,
384
+ },
385
+ )
386
+ return report
387
+
388
+ async def _schema(self, session: AsyncSession, domain_id: str, branch: str) -> Schema:
389
+ writes = self._runtime.settings.writes
390
+ compiled = await MaterializedRepository(session, domain_id, branch, writes=writes).schema()
391
+ if compiled is None:
392
+ raise SourceNotFoundError(f"branch {branch!r} of {domain_id!r} is not materialized")
393
+ return load_compiled(compiled.compiled)
394
+
395
+ async def _finish(
396
+ self, report: SyncReport, status: RunStatus, error: dict[str, Any] | None = None
397
+ ) -> None:
398
+ stats = report.as_dict()
399
+ revision = bytes.fromhex(report.revisions[-1]) if report.revisions else None
400
+ async with self._sessions() as session, session.begin():
401
+ await scope_to_domain(session, report.domain)
402
+ await SourceRunRepository(session, report.domain).finish(
403
+ report.run_id,
404
+ status=status,
405
+ finished_at=self._clock(),
406
+ stats=stats,
407
+ revision_id=revision,
408
+ proposal_id=report.proposal_id,
409
+ error=error,
410
+ )
411
+
412
+ async def _run(
413
+ self, domain: DomainRow, schema: Schema, source: Source, report: SyncReport
414
+ ) -> None:
415
+ mapper = RowMapper(schema, source, limits=self._runtime.schema.expression_limits)
416
+ since = await self._since(domain.id, source)
417
+ read = await self._read(mapper, source, since, report)
418
+ async with self._sessions() as session, session.begin():
419
+ await scope_to_domain(session, domain.id)
420
+ state_branch = await self._prepare_branch(session, domain, report)
421
+ current = await self._current(session, domain.id, state_branch, mapper, source)
422
+ delete_missing = source.sync_mode is SyncMode.SNAPSHOT and report.rows_failed == 0
423
+ delta = compute_delta(
424
+ mapper,
425
+ read.rows,
426
+ current,
427
+ delete_missing=delete_missing,
428
+ provenance=lambda row: self._provenance(source, report.run_id, row),
429
+ )
430
+ report.counts = delta.counts()
431
+ SOURCE_CHANGES.add(
432
+ delta.change_count, {"lorebase.source": source.api_name, "dry_run": report.dry_run}
433
+ )
434
+ if report.dry_run:
435
+ return
436
+ await self._commit(domain.id, source, delta, report)
437
+ if read.watermark is not None:
438
+ report.watermark = encode_watermark(read.watermark)
439
+ else:
440
+ previous = await self._previous_watermark(domain.id, source)
441
+ report.watermark = previous
442
+ await self._propose(domain, schema, source, report)
443
+
444
+ # Reading --------------------------------------------------------------------------------
445
+
446
+ async def _previous_watermark(
447
+ self, domain_id: str, source: Source
448
+ ) -> dict[str, JsonValue] | None:
449
+ if source.sync_mode is not SyncMode.INCREMENTAL:
450
+ return None
451
+ async with self._sessions() as session, session.begin():
452
+ await scope_to_domain(session, domain_id)
453
+ runs = await SourceRunRepository(session, domain_id).page(source.id, limit=50)
454
+ for run in runs:
455
+ stats = run.stats or {}
456
+ if run.status is RunStatus.SUCCEEDED and not stats.get("dry_run"):
457
+ watermark = stats.get("watermark")
458
+ return watermark if isinstance(watermark, dict) else None
459
+ return None
460
+
461
+ async def _since(self, domain_id: str, source: Source) -> Any:
462
+ return decode_watermark(await self._previous_watermark(domain_id, source))
463
+
464
+ async def _read(
465
+ self, mapper: RowMapper, source: Source, since: Any, report: SyncReport
466
+ ) -> _Read:
467
+ connector = self._connectors(source, self._settings)
468
+ read = _Read()
469
+ watermark_column = source.watermark_column
470
+ checked = False
471
+ # Keys seen in the rows of a Source without fixed columns (JSON records): the mapped
472
+ # columns are checked against all of them once the rows are read. Expressions are
473
+ # compiled over all of them too, so such rows are mapped once every row was read.
474
+ seen: dict[str, None] = {}
475
+ held: list[tuple[str, Row]] = []
476
+ async for locator, row in connector.rows(since):
477
+ if not connector.columns:
478
+ seen.update(dict.fromkeys(row))
479
+ elif not checked:
480
+ mapper.check_columns(connector.columns)
481
+ mapper.use_columns(connector.columns)
482
+ checked = True
483
+ report.rows_read += 1
484
+ if watermark_column is not None and row.get(watermark_column) not in (None, ""):
485
+ read.watermark = _later(read.watermark, row[watermark_column])
486
+ if not connector.columns and mapper.has_expressions:
487
+ held.append((locator, row))
488
+ continue
489
+ self._map_row(mapper, row, locator, read, report, seen)
490
+ if seen:
491
+ mapper.use_columns(tuple(seen))
492
+ for locator, row in held:
493
+ self._map_row(mapper, row, locator, read, report, seen)
494
+ if seen:
495
+ mapper.check_columns(tuple(seen))
496
+ report.rows_mapped = len(read.rows)
497
+ SOURCE_ROWS.add(report.rows_read, {"lorebase.source": source.api_name})
498
+ return read
499
+
500
+ def _map_row(
501
+ self,
502
+ mapper: RowMapper,
503
+ row: Row,
504
+ locator: str,
505
+ read: _Read,
506
+ report: SyncReport,
507
+ seen: Mapping[str, None],
508
+ ) -> None:
509
+ try:
510
+ read.rows.append(mapper.map(row, locator))
511
+ except RowError as error:
512
+ report.rows_failed += 1
513
+ if len(report.errors) < self._settings.error_samples:
514
+ report.errors.append(error.as_dict())
515
+ if report.rows_failed > self._settings.max_row_errors:
516
+ if seen: # a mapped column no row had explains the failures better
517
+ mapper.check_columns(tuple(seen))
518
+ raise SourceSyncError(
519
+ f"more than {self._settings.max_row_errors} rows failed mapping; nothing "
520
+ "was committed",
521
+ {"rows_failed": report.rows_failed},
522
+ ) from None
523
+
524
+ # Branch and state ---------------------------------------------------------------------------
525
+
526
+ async def _open_proposal(
527
+ self, session: AsyncSession, domain_id: str, branch: str, target: str
528
+ ) -> ProposalRow | None:
529
+ found = await ProposalRepository(session, domain_id).page(
530
+ ProposalFilter(status=ProposalStatus.OPEN, source_ref=branch, target_ref=target),
531
+ limit=1,
532
+ )
533
+ return found[0] if found else None
534
+
535
+ async def _prepare_branch(
536
+ self, session: AsyncSession, domain: DomainRow, report: SyncReport
537
+ ) -> str:
538
+ """Create or refresh the Source branch; returns the branch the delta is computed on."""
539
+ refs = RefRepository(session, domain.id)
540
+ branch, target = report.branch, report.target
541
+ ref = await refs.get(branch)
542
+ target_ref = await refs.require(target)
543
+ stale = (
544
+ ref is not None
545
+ and ref.head != target_ref.head
546
+ and not ref.protected
547
+ and await self._open_proposal(session, domain.id, branch, target) is None
548
+ )
549
+ if report.dry_run:
550
+ return target if ref is None or stale else branch
551
+ if ref is not None and not stale:
552
+ return branch
553
+ if ref is not None:
554
+ await refs.lock(branch, timeout_ms=self._runtime.settings.commit.lock_timeout_ms)
555
+ await refs.delete(branch)
556
+ await create_branch(
557
+ session,
558
+ domain.id,
559
+ branch,
560
+ created_by=self._principal,
561
+ source=target,
562
+ runtime=self._runtime,
563
+ clock=self._clock,
564
+ )
565
+ return branch
566
+
567
+ async def _current(
568
+ self,
569
+ session: AsyncSession,
570
+ domain_id: str,
571
+ branch: str,
572
+ mapper: RowMapper,
573
+ source: Source,
574
+ ) -> CurrentState:
575
+ settings = self._runtime.settings
576
+ rows = MaterializedRepository(session, domain_id, branch, writes=settings.writes)
577
+ batch = settings.materialize.read_batch_rows
578
+ records: dict[str, StoredRecord] = {}
579
+ async for page in rows.stream_records(mapper.kind.id, batch=batch):
580
+ for row in page:
581
+ records[row.key] = StoredRecord(row.object_hash, row.values)
582
+ owned = await rows.record_keys_with_origin(
583
+ mapper.kind.id,
584
+ origin_type=SYNC_ORIGIN,
585
+ origin_field="source_id",
586
+ origin_value=source.id,
587
+ )
588
+ links: dict[str, dict[str, frozenset[str]]] = {}
589
+ for relation_id in mapper.relations:
590
+ by_from: dict[str, set[str]] = defaultdict(set)
591
+ async for link_page in rows.stream_links(relation_id, batch=batch):
592
+ for link in link_page:
593
+ by_from[link.from_key].add(link.to_key)
594
+ links[relation_id] = {key: frozenset(to) for key, to in by_from.items()}
595
+ return CurrentState(records=records, owned=frozenset(owned), links=links)
596
+
597
+ def _provenance(self, source: Source, run_id: str, row: MappedRow) -> ProvenanceEntry:
598
+ return ProvenanceEntry(
599
+ id=new_id(IdPrefix.PROVENANCE),
600
+ origin=SourceSyncOrigin(source_id=source.id, run_id=run_id, row_locator=row.locator),
601
+ actor=self._principal,
602
+ at=format_timestamp(self._clock()),
603
+ )
604
+
605
+ # Writing ----------------------------------------------------------------------------------
606
+
607
+ async def _commit(
608
+ self, domain_id: str, source: Source, delta: Delta, report: SyncReport
609
+ ) -> None:
610
+ planned = batches(delta.groups, self._settings.commit_batch)
611
+ counts = delta.counts()
612
+ summary = f"+{counts['added']} ~{counts['changed']} -{counts['removed']}"
613
+ for index, batch in enumerate(planned, start=1):
614
+ changes = [change for group in batch for change in group.changes]
615
+ entries = {c.provenance.id: c.provenance for c in changes if c.provenance is not None}
616
+ part = f" (batch {index} of {len(planned)})" if len(planned) > 1 else ""
617
+ try:
618
+ async with self._sessions() as session, session.begin():
619
+ result = await commit(
620
+ session,
621
+ domain_id,
622
+ report.branch,
623
+ [
624
+ Change(c.change, None if c.provenance is None else c.provenance.id)
625
+ for c in changes
626
+ ],
627
+ author=Author(principal=self._principal),
628
+ message=f"Sync {source.api_name}: {summary}{part}",
629
+ origin=Origin(type=SYNC_ORIGIN, ref=report.run_id),
630
+ provenance=list(entries.values()),
631
+ runtime=self._runtime,
632
+ clock=self._clock,
633
+ )
634
+ except ValidationFailedError as error:
635
+ raise SourceSyncError(
636
+ f"the branch rejected batch {index}: {error}",
637
+ {"issues": _issues(error)},
638
+ ) from None
639
+ if not result.no_op:
640
+ report.revisions.append(result.revision_id.hex())
641
+
642
+ async def _propose(
643
+ self, domain: DomainRow, schema: Schema, source: Source, report: SyncReport
644
+ ) -> None:
645
+ policy = merge_policy(domain, schema, source)
646
+ mode = source.target_branch_policy.merge_policy
647
+ counts = report.counts
648
+ title = (
649
+ f"Sync {source.api_name}: {counts.get('added', 0)} added, "
650
+ f"{counts.get('changed', 0)} changed, {counts.get('removed', 0)} removed"
651
+ )
652
+ description = (
653
+ f"Source {source.api_name} ({source.id}), run {report.run_id}: "
654
+ f"{report.rows_read} rows read, {report.rows_failed} skipped; "
655
+ f"links +{counts.get('links_added', 0)} -{counts.get('links_removed', 0)}."
656
+ )
657
+ async with self._sessions() as session, session.begin():
658
+ await scope_to_domain(session, domain.id)
659
+ proposals = ProposalRepository(session, domain.id)
660
+ proposal = await proposals.by_key(self._principal, report.run_id)
661
+ if proposal is None:
662
+ proposal = await self._open_proposal(
663
+ session, domain.id, report.branch, report.target
664
+ )
665
+ automatic = mode is not SourcePolicy.PROPOSAL
666
+ if not report.revisions and (proposal is None or not automatic):
667
+ # Nothing new: an open Proposal is left as it is (a no-op run).
668
+ if proposal is not None:
669
+ report.proposal_id = proposal.id
670
+ report.proposal_status = proposal.status.value
671
+ return
672
+ now = self._clock()
673
+ stored = {POLICY_KEY: policy.model_dump(mode="json", exclude_defaults=True)}
674
+ if proposal is None:
675
+ proposal = ProposalRow(
676
+ id=f"{PROPOSAL_PREFIX}_{new_ulid()}",
677
+ title=title,
678
+ description=description,
679
+ source_ref=report.branch,
680
+ target_ref=report.target,
681
+ origin=ProposalOrigin.SOURCE_SYNC,
682
+ status=ProposalStatus.OPEN,
683
+ author=self._principal,
684
+ created_at=now,
685
+ checks={},
686
+ policies=stored,
687
+ idempotency_key=report.run_id,
688
+ updated_at=now,
689
+ )
690
+ await proposals.insert(proposal)
691
+ elif report.revisions and proposal.status is ProposalStatus.OPEN:
692
+ updated = await proposals.update(
693
+ proposal.id, at=now, title=title, description=description, policies=stored
694
+ )
695
+ assert updated is not None
696
+ proposal = updated
697
+ report.proposal_id = proposal.id
698
+ if proposal.status is not ProposalStatus.OPEN:
699
+ report.proposal_status = proposal.status.value
700
+ return
701
+ result = await self._merge(session, domain.id, proposal, policy, mode, report)
702
+ report.merge_status = result.attempt.status.value
703
+ if result.computation is not None:
704
+ report.conflicts = result.computation.open_count
705
+ final = await proposals.get(proposal.id)
706
+ report.proposal_status = None if final is None else final.status.value
707
+
708
+ async def _merge(
709
+ self,
710
+ session: AsyncSession,
711
+ domain_id: str,
712
+ proposal: ProposalRow,
713
+ policy: MergePolicy,
714
+ mode: SourcePolicy,
715
+ report: SyncReport,
716
+ ) -> AttemptResult:
717
+ service = MergeService(session, domain_id, runtime=self._runtime, clock=self._clock)
718
+ request = MergeRequest(
719
+ proposal=proposal,
720
+ actor=self._principal,
721
+ policy=policy,
722
+ idempotency_key=report.run_id,
723
+ )
724
+ automatic = mode in (SourcePolicy.AUTO_WHEN_CLEAN, SourcePolicy.AUTO_WITH_AUTHORITY)
725
+ if automatic and not self._settings.auto_merge_protected:
726
+ target = await RefRepository(session, domain_id).require(report.target)
727
+ automatic = not target.protected
728
+ if automatic:
729
+ return await service.merge(request)
730
+ return await service.recompute(request)