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,436 @@
1
+ """Incremental materialization: apply one Revision's changeset to a branch head (design 6.11).
2
+
3
+ Runs inside the commit transaction (invariant I4). Rows are written in bulk: one statement
4
+ batch per partition and operation, then the derived values the changeset can affect
5
+ (`materialize.derived`; every derived value when the schema changed) and the Links of
6
+ materialized path Derivations (`materialize.paths`; every one when the schema or
7
+ `store.traverse.path_fanout` changed), then one set-based
8
+ search vector update per Kind.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections import defaultdict
14
+ from collections.abc import Mapping, Sequence
15
+ from dataclasses import dataclass
16
+ from datetime import datetime
17
+ from typing import Any
18
+
19
+ from sqlalchemy.ext.asyncio import AsyncSession
20
+
21
+ from lorebase_core.expr import ExprLimits
22
+ from lorebase_core.model import Schema
23
+ from lorebase_core.revision import ChangesetEntry
24
+ from lorebase_store.materialize.derived import AppliedChanges, DerivedValues
25
+ from lorebase_store.materialize.overlay import check_overlay, promote
26
+ from lorebase_store.materialize.paths import PathLinks
27
+ from lorebase_store.materialize.rows import (
28
+ PartitionType,
29
+ next_lineage,
30
+ next_lineage_revisions,
31
+ parse_partition,
32
+ record_title,
33
+ search_weights,
34
+ split_link_key,
35
+ )
36
+ from lorebase_store.materialize.sensitivity import branch_tagged_schema
37
+ from lorebase_store.repositories.materializations import BranchMaterializationRepository
38
+ from lorebase_store.repositories.materialized import (
39
+ HistoryRow,
40
+ LinkRow,
41
+ MaterializedRepository,
42
+ RecordRow,
43
+ SchemaRow,
44
+ )
45
+ from lorebase_store.repositories.revisions import RevisionRepository
46
+ from lorebase_store.runtime import StoreRuntime
47
+ from lorebase_store.settings import StoreSettings
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class MaterializedChange:
52
+ """A changeset entry with the new object body (`None` when the element is removed)."""
53
+
54
+ entry: ChangesetEntry
55
+ body: Mapping[str, Any] | None
56
+
57
+
58
+ async def apply_changeset(
59
+ session: AsyncSession,
60
+ domain_id: str,
61
+ branch: str,
62
+ *,
63
+ revision_id: bytes,
64
+ at: datetime,
65
+ changes: Sequence[MaterializedChange],
66
+ schema: Schema,
67
+ schema_hash: bytes | None,
68
+ refresh_kinds: Sequence[str] = (),
69
+ settings: StoreSettings,
70
+ expression_limits: ExprLimits | None = None,
71
+ runtime: StoreRuntime | None = None,
72
+ lineage_source: str | None = None,
73
+ fast_forward_from: bytes | None = None,
74
+ ) -> None:
75
+ """Apply a Revision's changeset to the materialized head of `branch`.
76
+
77
+ `lineage_source` names the branch a fast-forward moves `branch` to: the branch's
78
+ first-parent chain becomes the source's, so a written Record that the source holds with
79
+ the same object takes the source row's lineage, lineage revisions and update Revision (a
80
+ Link, its lineage), as
81
+ a rebuild folding that chain would (one primary-key read per batch of changed keys; a
82
+ source row without lineage revisions is folded from the changeset instead).
83
+ `fast_forward_from` is the branch's head before that fast-forward: when it lies on the
84
+ first-parent chain of `revision_id` within `materialize.fast_forward_history_max_revisions`
85
+ Revisions, the branch history gets one row per change of each of them, as a rebuild
86
+ writes it; otherwise one row per change at `revision_id`.
87
+
88
+ `schema` is the schema at the Revision; `schema_hash` is its schema partition hash when
89
+ the schema changed. `refresh_kinds` lists Kinds whose title or search settings changed:
90
+ their title and search vectors are recomputed for every Record. `expression_limits`
91
+ bound the evaluation of Derivations (`schema.expression_limits`).
92
+
93
+ The caller holds the branch lock. When the schema changes, an overlay over a small base
94
+ is promoted to a full copy first; an overlay over a large base, or one whose background
95
+ copy is queued or running, keeps its overlay rows: the change is applied to the rows the
96
+ changeset writes, and the copy job (queued if needed) recomputes the rest after the
97
+ switch (`reconcile`, `materialize.background`). An overlay over its limits is promoted
98
+ afterwards (`materialize.overlay`); overlays based on the branch get the previous state
99
+ of the rows it changes. A `building` branch raises `BranchMaterializingError`.
100
+ """
101
+ runtime = runtime or StoreRuntime.from_settings(settings)
102
+ # Titles and search vectors use the default branch's tags too (`materialize.sensitivity`).
103
+ tagged = await branch_tagged_schema(session, domain_id, branch, schema, runtime=runtime)
104
+ generation = (await RevisionRepository(session, domain_id).generations([revision_id]))[
105
+ revision_id
106
+ ]
107
+ repository = MaterializedRepository(
108
+ session,
109
+ domain_id,
110
+ branch,
111
+ writes=settings.writes,
112
+ generation=generation,
113
+ lock_timeout_ms=settings.commit.lock_timeout_ms,
114
+ )
115
+ layout = await repository.layout()
116
+ if layout.building:
117
+ # Imported here: the commit package imports the materializer.
118
+ from lorebase_store.commit.errors import BranchMaterializingError
119
+
120
+ raise BranchMaterializingError(
121
+ f"branch {branch!r} is being built from its tree in the background; retry when "
122
+ "its materialization is done"
123
+ )
124
+ deferred = False
125
+ if schema_hash is not None and layout.overlay:
126
+ deferred = await defer_schema_change(
127
+ session, domain_id, branch, refresh_kinds=refresh_kinds, at=at, runtime=runtime
128
+ )
129
+ if not deferred:
130
+ await promote(session, domain_id, branch, runtime=runtime, locked=True)
131
+ repository.forget_layout()
132
+ fast_forwarded = (
133
+ None
134
+ if fast_forward_from is None
135
+ else await repository.first_parent_range(
136
+ revision_id,
137
+ fast_forward_from,
138
+ limit=settings.materialize.fast_forward_history_max_revisions,
139
+ )
140
+ )
141
+ if fast_forwarded is not None:
142
+ await repository.insert_chain_history(fast_forwarded)
143
+ else:
144
+ await repository.insert_history(
145
+ [
146
+ HistoryRow(
147
+ partition=change.entry.partition,
148
+ element_key=change.entry.key,
149
+ revision_id=revision_id,
150
+ at=at,
151
+ old_hash=change.entry.old_hash,
152
+ new_hash=change.entry.new_hash,
153
+ provenance=dict(change.entry.provenance) or None,
154
+ )
155
+ for change in changes
156
+ ]
157
+ )
158
+ if schema_hash is not None:
159
+ await repository.set_schema(SchemaRow(schema_hash, schema.content()))
160
+ by_partition: dict[str, list[MaterializedChange]] = defaultdict(list)
161
+ for change in changes:
162
+ by_partition[change.entry.partition].append(change)
163
+ derived = None
164
+ if settings.materialize.derivations_enabled:
165
+ derived = DerivedValues(
166
+ repository,
167
+ schema,
168
+ limits=expression_limits or ExprLimits(),
169
+ now=at,
170
+ batch=settings.materialize.read_batch_rows,
171
+ max_incremental=settings.materialize.derivation_max_incremental_records,
172
+ fanout=settings.traverse.path_fanout,
173
+ )
174
+ paths = None
175
+ if settings.materialize.derivations_enabled:
176
+ paths = PathLinks(
177
+ repository,
178
+ schema,
179
+ fanout=settings.traverse.path_fanout,
180
+ batch=settings.materialize.read_batch_rows,
181
+ max_incremental=settings.materialize.derivation_max_incremental_records,
182
+ )
183
+ source = (
184
+ None
185
+ if lineage_source is None
186
+ else MaterializedRepository(session, domain_id, lineage_source, writes=settings.writes)
187
+ )
188
+ applied = AppliedChanges()
189
+ keep_previous = (derived is not None and bool(derived.steps)) or bool(paths and paths.paths)
190
+ written_kinds: list[str] = []
191
+ for partition, group in sorted(by_partition.items()):
192
+ parsed = parse_partition(partition)
193
+ if parsed.type is PartitionType.KIND:
194
+ await _apply_records(
195
+ repository,
196
+ parsed.element_id,
197
+ group,
198
+ tagged,
199
+ revision_id,
200
+ at,
201
+ applied,
202
+ keep_previous=keep_previous,
203
+ source=source,
204
+ )
205
+ written_kinds.append(parsed.element_id)
206
+ elif parsed.type is PartitionType.RELATION:
207
+ await _apply_links(repository, parsed.element_id, group, source=source)
208
+ applied.links[parsed.element_id].update(split_link_key(c.entry.key) for c in group)
209
+ # A deferred schema change recomputes only what the changeset touched; the copy job
210
+ # reconciles every other row after its switch.
211
+ whole = schema_hash is not None and not deferred
212
+ changed: dict[str, set[str]] = {}
213
+ # A change of `store.traverse.path_fanout` recomputes whatever follows it, once.
214
+ stale = schema_hash is None and paths is not None and await paths.fanout_stale(derived)
215
+ if derived is not None:
216
+ if whole:
217
+ await derived.prune()
218
+ changed = await derived.recompute_all()
219
+ elif stale and derived.reads_paths:
220
+ changed = await derived.recompute_all()
221
+ elif derived.steps:
222
+ changed = await derived.recompute(applied)
223
+ if paths is not None:
224
+ if whole or stale:
225
+ if whole:
226
+ await paths.prune()
227
+ await paths.recompute_all()
228
+ await paths.record_fanout()
229
+ elif paths.paths:
230
+ await paths.recompute(applied)
231
+ if deferred:
232
+ refresh_kinds = ()
233
+ search_config = settings.materialize.text_search_config
234
+ for kind_id in written_kinds:
235
+ if kind_id not in refresh_kinds:
236
+ await repository.refresh_search(
237
+ kind_id,
238
+ search_weights(tagged.kinds.get(kind_id)),
239
+ config=search_config,
240
+ revision=revision_id,
241
+ keys=sorted(applied.written.get(kind_id, ())),
242
+ )
243
+ for kind_id, keys in sorted(changed.items()):
244
+ if keys and kind_id not in refresh_kinds and derived and derived.searchable(kind_id):
245
+ await repository.refresh_search(
246
+ kind_id,
247
+ search_weights(tagged.kinds.get(kind_id)),
248
+ config=search_config,
249
+ keys=sorted(keys),
250
+ )
251
+ for kind_id in refresh_kinds:
252
+ await refresh_kind(repository, tagged, kind_id, settings=settings)
253
+ await check_overlay(repository, generation=generation, runtime=runtime)
254
+
255
+
256
+ async def defer_schema_change(
257
+ session: AsyncSession,
258
+ domain_id: str,
259
+ branch: str,
260
+ *,
261
+ refresh_kinds: Sequence[str],
262
+ at: datetime,
263
+ runtime: StoreRuntime,
264
+ ) -> bool:
265
+ """For a schema change on the overlay `branch`: whether its background copy takes it
266
+ over (`reconcile`) instead of a promotion in the writing transaction. It does when a copy
267
+ is queued or running, or when the base holds more than `async_copy_min_rows` rows (the
268
+ copy is then queued here)."""
269
+ # Imported here: the background jobs import the overlay helpers this module uses.
270
+ from lorebase_store.materialize.background import queue_materialization
271
+ from lorebase_store.materialize.overlay import base_is_large
272
+ from lorebase_store.repositories.refs import RefRepository
273
+
274
+ states = BranchMaterializationRepository(session, domain_id)
275
+ if not await states.active(branch):
276
+ ref = await RefRepository(session, domain_id).get(branch)
277
+ if ref is None or ref.base_branch is None:
278
+ return False
279
+ if not await base_is_large(session, domain_id, ref.base_branch, runtime=runtime):
280
+ return False
281
+ await queue_materialization(session, domain_id, ref, at=at)
282
+ await states.add_reconcile(branch, refresh_kinds=refresh_kinds, at=at)
283
+ return True
284
+
285
+
286
+ async def refresh_kind(
287
+ repository: MaterializedRepository, schema: Schema, kind_id: str, *, settings: StoreSettings
288
+ ) -> None:
289
+ """Recompute title and search vector of every Record of a Kind."""
290
+ kind = schema.kinds.get(kind_id)
291
+ async for rows in repository.stream_records(
292
+ kind_id, batch=settings.materialize.read_batch_rows
293
+ ):
294
+ await repository.update_titles(
295
+ kind_id, [(row.key, record_title(kind, row.values)) for row in rows]
296
+ )
297
+ await repository.refresh_search(
298
+ kind_id, search_weights(kind), config=settings.materialize.text_search_config
299
+ )
300
+
301
+
302
+ async def _apply_records(
303
+ repository: MaterializedRepository,
304
+ kind_id: str,
305
+ changes: Sequence[MaterializedChange],
306
+ schema: Schema,
307
+ revision_id: bytes,
308
+ at: datetime,
309
+ applied: AppliedChanges,
310
+ *,
311
+ keep_previous: bool,
312
+ source: MaterializedRepository | None = None,
313
+ ) -> None:
314
+ removed = [c.entry.key for c in changes if c.body is None]
315
+ written = [c for c in changes if c.body is not None]
316
+ updated = [c.entry.key for c in written if c.entry.old_hash is not None]
317
+ wanted = [*updated, *removed] if keep_previous else updated
318
+ previous = await repository.records(kind_id, wanted) if wanted else {}
319
+ applied.written[kind_id].update(c.entry.key for c in written)
320
+ applied.removed[kind_id].update(removed)
321
+ if keep_previous:
322
+ for key in [*updated, *removed]:
323
+ row = previous.get(key)
324
+ applied.previous[(kind_id, key)] = None if row is None else row.values
325
+ if removed:
326
+ await repository.delete_records(kind_id, removed)
327
+ if not written:
328
+ return
329
+ kind = schema.kinds.get(kind_id)
330
+ sourced = (
331
+ {} if source is None else await source.records(kind_id, [c.entry.key for c in written])
332
+ )
333
+ rows: list[RecordRow] = []
334
+ for change in written:
335
+ assert change.body is not None
336
+ assert change.entry.new_hash is not None
337
+ values: dict[str, Any] = dict(change.body.get("values", {}))
338
+ same = sourced.get(change.entry.key)
339
+ if (
340
+ same is not None
341
+ and same.object_hash == change.entry.new_hash
342
+ and same.lineage_revisions is not None
343
+ ):
344
+ rows.append(
345
+ RecordRow(
346
+ kind_id=kind_id,
347
+ key=change.entry.key,
348
+ values=values,
349
+ title=record_title(kind, values),
350
+ object_hash=change.entry.new_hash,
351
+ lineage=dict(same.lineage),
352
+ updated_revision=same.updated_revision,
353
+ updated_at=same.updated_at,
354
+ lineage_revisions=dict(same.lineage_revisions),
355
+ )
356
+ )
357
+ continue
358
+ before = previous.get(change.entry.key)
359
+ rows.append(
360
+ RecordRow(
361
+ kind_id=kind_id,
362
+ key=change.entry.key,
363
+ values=values,
364
+ title=record_title(kind, values),
365
+ object_hash=change.entry.new_hash,
366
+ lineage=next_lineage(
367
+ None if before is None else before.lineage, change.entry.provenance, values
368
+ ),
369
+ updated_revision=revision_id,
370
+ updated_at=at,
371
+ lineage_revisions=next_lineage_revisions(
372
+ None if before is None else before.lineage_revisions,
373
+ change.entry.provenance,
374
+ revision_id,
375
+ values,
376
+ created=change.entry.old_hash is None,
377
+ ),
378
+ )
379
+ )
380
+ await repository.upsert_records(rows)
381
+
382
+
383
+ async def _apply_links(
384
+ repository: MaterializedRepository,
385
+ relation_id: str,
386
+ changes: Sequence[MaterializedChange],
387
+ *,
388
+ source: MaterializedRepository | None = None,
389
+ ) -> None:
390
+ removed = [split_link_key(c.entry.key) for c in changes if c.body is None]
391
+ written = [c for c in changes if c.body is not None]
392
+ if removed:
393
+ await repository.delete_links(relation_id, removed)
394
+ if not written:
395
+ return
396
+ updated = [split_link_key(c.entry.key) for c in written if c.entry.old_hash is not None]
397
+ previous = await repository.links(relation_id, updated) if updated else {}
398
+ sourced = (
399
+ {}
400
+ if source is None
401
+ else await source.links(relation_id, [split_link_key(c.entry.key) for c in written])
402
+ )
403
+ rows: list[LinkRow] = []
404
+ for change in written:
405
+ assert change.body is not None
406
+ assert change.entry.new_hash is not None
407
+ from_key, to_key = split_link_key(change.entry.key)
408
+ values: dict[str, Any] = dict(change.body.get("values", {}))
409
+ before = previous.get((from_key, to_key))
410
+ same = sourced.get((from_key, to_key))
411
+ if same is not None and same.object_hash == change.entry.new_hash:
412
+ # A fast-forward: the source's Lineage, as a rebuild folding its chain gives.
413
+ rows.append(
414
+ LinkRow(
415
+ relation_id=relation_id,
416
+ from_key=from_key,
417
+ to_key=to_key,
418
+ values=values,
419
+ object_hash=change.entry.new_hash,
420
+ lineage=dict(same.lineage),
421
+ )
422
+ )
423
+ continue
424
+ rows.append(
425
+ LinkRow(
426
+ relation_id=relation_id,
427
+ from_key=from_key,
428
+ to_key=to_key,
429
+ values=values,
430
+ object_hash=change.entry.new_hash,
431
+ lineage=next_lineage(
432
+ None if before is None else before.lineage, change.entry.provenance, values
433
+ ),
434
+ )
435
+ )
436
+ await repository.upsert_links(rows)