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,1129 @@
1
+ """Materialized head of one branch: Records, Links, history and compiled schema (design 7.2)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator, Callable, Iterable, Sequence
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime
8
+ from typing import Any
9
+
10
+ from sqlalchemy import (
11
+ ARRAY,
12
+ BigInteger,
13
+ BindParameter,
14
+ ColumnElement,
15
+ FromClause,
16
+ Select,
17
+ Table,
18
+ Text,
19
+ and_,
20
+ bindparam,
21
+ cast,
22
+ delete,
23
+ false,
24
+ func,
25
+ insert,
26
+ literal,
27
+ literal_column,
28
+ select,
29
+ text,
30
+ true,
31
+ tuple_,
32
+ update,
33
+ )
34
+ from sqlalchemy.dialects.postgresql import JSONB, REGCONFIG
35
+ from sqlalchemy.dialects.postgresql import insert as pg_insert
36
+ from sqlalchemy.ext.asyncio import AsyncSession
37
+
38
+ from lorebase_store.bulk import OnConflict, bulk_write, chunks, pairs_in
39
+ from lorebase_store.layout import BranchLayout, lineage_column, load_layout
40
+ from lorebase_store.models import metadata
41
+ from lorebase_store.repositories.refs import RefRepository
42
+ from lorebase_store.settings import WriteSettings
43
+
44
+ MAT_SCHEMA: Table = metadata.tables["mat_schema"]
45
+ MAT_RECORDS: Table = metadata.tables["mat_records"]
46
+ MAT_LINKS: Table = metadata.tables["mat_links"]
47
+ MAT_HISTORY: Table = metadata.tables["mat_history"]
48
+ REVISIONS: Table = metadata.tables["revisions"]
49
+
50
+ MERGE_ORIGIN_TYPE = "merge"
51
+ """Origin type of merge Revisions (`merging.MERGE_ORIGIN`)."""
52
+
53
+ DERIVED_LINK_PREFIX = "drv_"
54
+ """`relation_id` prefix of the rows of materialized path Derivations in `mat_links` (the
55
+ Derivation id): they are not tree objects, so tree comparisons leave them out."""
56
+
57
+ SEARCH_WEIGHTS = ("A", "B", "C", "D")
58
+ """Valid full-text weights (Postgres `setweight`)."""
59
+
60
+ _RECORD_UPDATE_COLUMNS = (
61
+ "values",
62
+ "title",
63
+ "object_hash",
64
+ "lineage",
65
+ "updated_revision",
66
+ "updated_at",
67
+ "revision_generation",
68
+ "deleted",
69
+ "lineage_revisions",
70
+ )
71
+ _LINK_UPDATE_COLUMNS = ("values", "object_hash", "lineage", "revision_generation", "deleted")
72
+
73
+ Where = Callable[[FromClause], Sequence[ColumnElement[bool]]]
74
+ """Conditions on the rows (of the alias given) a write is about to change."""
75
+
76
+ _REBUILD_HISTORY = text(
77
+ """
78
+ WITH RECURSIVE chain(id) AS (
79
+ SELECT CAST(:head AS bytea)
80
+ UNION ALL
81
+ SELECT p.parent_id
82
+ FROM chain
83
+ JOIN revision_parents p
84
+ ON p.domain_id = :domain AND p.revision_id = chain.id AND p.position = 0
85
+ )
86
+ INSERT INTO mat_history
87
+ (domain_id, branch, partition, element_key, revision_id, at, old_hash, new_hash,
88
+ provenance)
89
+ SELECT :domain, :branch, c.partition, c.element_key, c.revision_id, r.created_at,
90
+ c.old_hash, c.new_hash, c.provenance
91
+ FROM chain
92
+ JOIN revisions r ON r.domain_id = :domain AND r.id = chain.id
93
+ JOIN changesets c ON c.domain_id = :domain AND c.revision_id = chain.id
94
+ """
95
+ )
96
+
97
+
98
+ _CHAIN_TO = text(
99
+ """
100
+ WITH RECURSIVE chain(id, depth) AS (
101
+ SELECT CAST(:head AS bytea), 0
102
+ UNION ALL
103
+ SELECT p.parent_id, chain.depth + 1
104
+ FROM chain
105
+ JOIN revision_parents p
106
+ ON p.domain_id = :domain AND p.revision_id = chain.id AND p.position = 0
107
+ WHERE chain.id <> :stop AND chain.depth < :limit
108
+ )
109
+ SELECT id FROM chain
110
+ """
111
+ )
112
+
113
+ _INSERT_CHAIN_HISTORY = text(
114
+ """
115
+ INSERT INTO mat_history
116
+ (domain_id, branch, partition, element_key, revision_id, at, old_hash, new_hash,
117
+ provenance)
118
+ SELECT :domain, :branch, c.partition, c.element_key, c.revision_id, r.created_at,
119
+ c.old_hash, c.new_hash, c.provenance
120
+ FROM revisions r
121
+ JOIN changesets c ON c.domain_id = :domain AND c.revision_id = r.id
122
+ WHERE r.domain_id = :domain AND r.id = ANY(CAST(:ids AS bytea[]))
123
+ """
124
+ )
125
+
126
+ _KEYS_WITH_ORIGIN = """
127
+ SELECT r.key FROM {records}
128
+ WHERE r.domain_id = :domain AND r.branch = :branch AND r.kind_id = :kind
129
+ AND EXISTS (
130
+ SELECT 1 FROM jsonb_each_text(lb_lineage(r.domain_id, r.lineage))
131
+ AS l(attribute, provenance_id)
132
+ JOIN provenance p ON p.domain_id = r.domain_id AND p.id = l.provenance_id
133
+ WHERE p.origin_type = :origin_type AND p.origin_ref ->> :field = :value
134
+ )
135
+ ORDER BY r.key
136
+ """
137
+
138
+
139
+ @dataclass(frozen=True, slots=True)
140
+ class RecordRow:
141
+ """A materialized Record. `derived` holds derived values by Attribute id (AC-07): they are
142
+ written by the materializer only, never by `upsert_records`. `lineage_revisions` is None
143
+ when unknown (`materialize.rows.next_lineage_revisions`)."""
144
+
145
+ kind_id: str
146
+ key: str
147
+ values: dict[str, Any]
148
+ title: str | None
149
+ object_hash: bytes
150
+ lineage: dict[str, str]
151
+ updated_revision: bytes
152
+ updated_at: datetime
153
+ derived: dict[str, Any] = field(default_factory=dict)
154
+ lineage_revisions: dict[str, str] | None = None
155
+
156
+ @property
157
+ def inputs(self) -> dict[str, Any]:
158
+ """Stored and derived values together, as expressions read them."""
159
+ return {**self.values, **self.derived}
160
+
161
+
162
+ @dataclass(frozen=True, slots=True)
163
+ class LinkRow:
164
+ relation_id: str
165
+ from_key: str
166
+ to_key: str
167
+ values: dict[str, Any]
168
+ object_hash: bytes
169
+ lineage: dict[str, str]
170
+
171
+
172
+ @dataclass(frozen=True, slots=True)
173
+ class HistoryRow:
174
+ partition: str
175
+ element_key: str
176
+ revision_id: bytes
177
+ at: datetime
178
+ old_hash: bytes | None
179
+ new_hash: bytes | None
180
+ provenance: dict[str, str] | None
181
+
182
+
183
+ @dataclass(frozen=True, slots=True)
184
+ class SchemaRow:
185
+ schema_hash: bytes
186
+ compiled: dict[str, Any]
187
+ path_fanout: int | None = None
188
+
189
+
190
+ @dataclass(frozen=True, slots=True)
191
+ class HistoryStep:
192
+ """One change of an element along the branch, in generation order."""
193
+
194
+ element_key: str
195
+ revision_id: bytes
196
+ at: datetime
197
+ new_hash: bytes | None
198
+ provenance: dict[str, str] | None
199
+ old_hash: bytes | None
200
+ merge: bool = False
201
+ """The change is a merge Revision's (`MERGE_ORIGIN_TYPE`)."""
202
+
203
+
204
+ def search_vector(
205
+ config: str, weights: Sequence[tuple[str, str]], rows: FromClause = MAT_RECORDS
206
+ ) -> ColumnElement[Any]:
207
+ """Search vector of a Record row: its title at weight A plus searchable Attributes.
208
+
209
+ `weights` lists `(attribute_id, weight)`; values are read as text from `values`, or from
210
+ `derived` for derived Attributes, of `rows` (`mat_records` or the rows an overlay reads).
211
+ """
212
+ regconfig = cast(literal(config), REGCONFIG)
213
+ vector: ColumnElement[Any] = func.setweight(
214
+ func.to_tsvector(regconfig, func.coalesce(rows.c.title, "")),
215
+ literal_column("'A'"),
216
+ )
217
+ for position, (attribute_id, weight) in enumerate(weights):
218
+ if weight not in SEARCH_WEIGHTS:
219
+ raise ValueError(f"invalid search weight {weight!r}")
220
+ name: BindParameter[str] = bindparam(f"search_attr_{position}", attribute_id)
221
+ value = func.coalesce(rows.c["values"].op("->>")(name), rows.c.derived.op("->>")(name))
222
+ part = func.setweight(
223
+ func.to_tsvector(regconfig, func.coalesce(value, "")), literal_column(f"'{weight}'")
224
+ )
225
+ vector = vector.op("||")(part)
226
+ return vector
227
+
228
+
229
+ class MaterializedRepository:
230
+ """Rows of the materialized head of one branch.
231
+
232
+ Reads see the branch's rows: its own, or for an overlay the resolved rows of
233
+ `lorebase_store.layout`. Writes keep overlays correct on both sides:
234
+
235
+ - on an overlay, a row about to change is first copied up from the base (so it keeps its
236
+ derived values and search vector), and a delete leaves a tombstone;
237
+ - on a branch other overlays are based on, a row about to change or go is first copied
238
+ into each of them that does not hold it yet (copy on write). Those overlays are locked
239
+ first (their branch locks, after the caller's lock on this branch), so no commit or
240
+ promotion of theirs runs meanwhile.
241
+
242
+ `generation` is the generation of the Revision whose rows are written; it is stored in
243
+ `revision_generation`, which hides rows written after an overlay's fork point from it.
244
+ """
245
+
246
+ def __init__(
247
+ self,
248
+ session: AsyncSession,
249
+ domain_id: str,
250
+ branch: str,
251
+ *,
252
+ writes: WriteSettings,
253
+ layout: BranchLayout | None = None,
254
+ generation: int | None = None,
255
+ lock_timeout_ms: int = 0,
256
+ ) -> None:
257
+ self._session = session
258
+ self._domain = domain_id
259
+ self._branch = branch
260
+ self._writes = writes
261
+ self._layout = layout
262
+ self._generation = generation
263
+ self._lock_timeout_ms = lock_timeout_ms
264
+ self._dependents: list[tuple[str, int]] | None = None
265
+
266
+ @property
267
+ def branch(self) -> str:
268
+ return self._branch
269
+
270
+ @property
271
+ def domain_id(self) -> str:
272
+ return self._domain
273
+
274
+ @property
275
+ def session(self) -> AsyncSession:
276
+ return self._session
277
+
278
+ async def layout(self) -> BranchLayout:
279
+ """Where the branch's rows live (read from its ref once)."""
280
+ if self._layout is None:
281
+ self._layout = await load_layout(self._session, self._domain, self._branch)
282
+ return self._layout
283
+
284
+ def forget_layout(self) -> None:
285
+ """Read the layout again on next use (after a promotion or a re-fork)."""
286
+ self._layout = None
287
+ self._dependents = None
288
+
289
+ async def dependents(self) -> list[tuple[str, int]]:
290
+ """`(name, base generation)` of the overlays based on this branch, locked."""
291
+ if self._dependents is not None:
292
+ return self._dependents
293
+ if (await self.layout()).overlay:
294
+ self._dependents = []
295
+ return self._dependents
296
+ refs = RefRepository(self._session, self._domain)
297
+ locked: set[str] = set()
298
+ while True:
299
+ found = await refs.overlays_of(self._branch)
300
+ pending = sorted(r.name for r in found if r.name not in locked)
301
+ if not pending:
302
+ break
303
+ for name in pending:
304
+ await refs.lock(name, timeout_ms=self._lock_timeout_ms)
305
+ locked.add(name)
306
+ self._dependents = [
307
+ (r.name, int(r.base_generation)) for r in found if r.base_generation is not None
308
+ ]
309
+ return self._dependents
310
+
311
+ def _scope(self, table: FromClause) -> ColumnElement[bool]:
312
+ return and_(table.c.domain_id == self._domain, table.c.branch == self._branch)
313
+
314
+ async def _records(self) -> FromClause:
315
+ return (await self.layout()).records()
316
+
317
+ async def _links(self) -> FromClause:
318
+ return (await self.layout()).links()
319
+
320
+ # Schema -------------------------------------------------------------------------
321
+
322
+ async def schema(self) -> SchemaRow | None:
323
+ result = await self._session.execute(
324
+ select(MAT_SCHEMA.c.schema_hash, MAT_SCHEMA.c.compiled, MAT_SCHEMA.c.path_fanout).where(
325
+ self._scope(MAT_SCHEMA)
326
+ )
327
+ )
328
+ found = result.one_or_none()
329
+ return None if found is None else SchemaRow(found[0], found[1], found[2])
330
+
331
+ async def set_schema(self, row: SchemaRow) -> None:
332
+ """Write the schema row; its `path_fanout` is kept unless `row` gives one."""
333
+ await bulk_write(
334
+ self._session,
335
+ MAT_SCHEMA,
336
+ [
337
+ {
338
+ "domain_id": self._domain,
339
+ "branch": self._branch,
340
+ "schema_hash": row.schema_hash,
341
+ "compiled": row.compiled,
342
+ "path_fanout": row.path_fanout,
343
+ }
344
+ ],
345
+ writes=self._writes,
346
+ on_conflict=OnConflict.UPDATE,
347
+ update_columns=(
348
+ ("schema_hash", "compiled")
349
+ if row.path_fanout is None
350
+ else ("schema_hash", "compiled", "path_fanout")
351
+ ),
352
+ )
353
+
354
+ async def path_fanout(self) -> int | None:
355
+ """The fan-out the branch's materialized paths were computed with."""
356
+ found = await self._session.scalar(
357
+ select(MAT_SCHEMA.c.path_fanout).where(self._scope(MAT_SCHEMA))
358
+ )
359
+ return None if found is None else int(found)
360
+
361
+ async def set_path_fanout(self, fanout: int) -> None:
362
+ await self._session.execute(
363
+ update(MAT_SCHEMA).where(self._scope(MAT_SCHEMA)).values(path_fanout=fanout)
364
+ )
365
+
366
+ # Records --------------------------------------------------------------------------
367
+
368
+ async def records(self, kind_id: str, keys: Iterable[str]) -> dict[str, RecordRow]:
369
+ rows = await self._records()
370
+ found: dict[str, RecordRow] = {}
371
+ for chunk in chunks(list(dict.fromkeys(keys)), self._writes.insert_batch_rows):
372
+ result = await self._session.execute(
373
+ _record_select(rows).where(
374
+ self._scope(rows), rows.c.kind_id == kind_id, rows.c.key.in_(chunk)
375
+ )
376
+ )
377
+ for row in result.mappings():
378
+ found[row["key"]] = RecordRow(**row)
379
+ return found
380
+
381
+ async def upsert_records(self, rows: Sequence[RecordRow]) -> None:
382
+ if not rows:
383
+ return
384
+ overlay = (await self.layout()).overlay
385
+ by_kind: dict[str, list[str]] = {}
386
+ for row in rows:
387
+ by_kind.setdefault(row.kind_id, []).append(row.key)
388
+ for kind_id, keys in by_kind.items():
389
+ await self._before_record_writes(kind_id, keys)
390
+ if overlay:
391
+ await self._revive_records(kind_id, keys)
392
+ await bulk_write(
393
+ self._session,
394
+ MAT_RECORDS,
395
+ [self._record_values(row) for row in rows],
396
+ writes=self._writes,
397
+ on_conflict=OnConflict.UPDATE,
398
+ update_columns=_RECORD_UPDATE_COLUMNS,
399
+ )
400
+
401
+ async def delete_records(self, kind_id: str, keys: Sequence[str]) -> None:
402
+ await self._before_record_writes(kind_id, keys)
403
+ overlay = (await self.layout()).overlay
404
+ for chunk in chunks(keys, self._writes.insert_batch_rows):
405
+ where = (
406
+ self._scope(MAT_RECORDS),
407
+ MAT_RECORDS.c.kind_id == kind_id,
408
+ MAT_RECORDS.c.key.in_(chunk),
409
+ )
410
+ if overlay:
411
+ await self._session.execute(
412
+ update(MAT_RECORDS)
413
+ .where(*where)
414
+ .values(deleted=true(), search=None, **self._stamp())
415
+ )
416
+ else:
417
+ await self._session.execute(delete(MAT_RECORDS).where(*where))
418
+
419
+ async def update_titles(self, kind_id: str, titles: Sequence[tuple[str, str | None]]) -> None:
420
+ await self._before_record_writes(kind_id, [key for key, _ in titles])
421
+ statement = (
422
+ update(MAT_RECORDS)
423
+ .where(
424
+ self._scope(MAT_RECORDS),
425
+ MAT_RECORDS.c.kind_id == kind_id,
426
+ MAT_RECORDS.c.key == bindparam("record_key"),
427
+ )
428
+ .values(title=bindparam("record_title"), **self._stamp())
429
+ )
430
+ for chunk in chunks(titles, self._writes.insert_batch_rows):
431
+ await self._session.execute(
432
+ statement, [{"record_key": key, "record_title": title} for key, title in chunk]
433
+ )
434
+
435
+ async def refresh_search(
436
+ self,
437
+ kind_id: str,
438
+ weights: Sequence[tuple[str, str]],
439
+ *,
440
+ config: str,
441
+ revision: bytes | None = None,
442
+ keys: Sequence[str] | None = None,
443
+ ) -> None:
444
+ """Recompute search vectors of a Kind's rows (only those of `revision`, or of `keys`,
445
+ when given). With both, `keys` are the keys `revision` wrote: they bound the update to
446
+ primary-key lookups, where `revision` alone filters every row of the Kind."""
447
+ if keys is not None and revision is None:
448
+ await self._before_record_writes(kind_id, keys)
449
+ elif keys is None and revision is None:
450
+ await self._before_writes(MAT_RECORDS, lambda s: [s.c.kind_id == kind_id])
451
+ # Rows of `revision` were written by it, after their previous state was preserved.
452
+ statement = (
453
+ update(MAT_RECORDS)
454
+ .where(self._scope(MAT_RECORDS), MAT_RECORDS.c.kind_id == kind_id)
455
+ .values(search=search_vector(config, weights), **self._stamp())
456
+ )
457
+ if revision is not None:
458
+ statement = statement.where(MAT_RECORDS.c.updated_revision == revision)
459
+ if keys is None:
460
+ await self._session.execute(statement)
461
+ return
462
+ for chunk in chunks(list(keys), self._writes.insert_batch_rows):
463
+ await self._session.execute(statement.where(MAT_RECORDS.c.key.in_(chunk)))
464
+
465
+ async def stale_search_keys(
466
+ self,
467
+ kind_id: str,
468
+ weights: Sequence[tuple[str, str]],
469
+ *,
470
+ config: str,
471
+ keys: Sequence[str],
472
+ ) -> list[str]:
473
+ """Keys among `keys` whose stored search vector differs from the one `weights`
474
+ compute now."""
475
+ rows = await self._records()
476
+ found: list[str] = []
477
+ for chunk in chunks(list(keys), self._writes.insert_batch_rows):
478
+ result = await self._session.execute(
479
+ select(rows.c.key).where(
480
+ self._scope(rows),
481
+ rows.c.kind_id == kind_id,
482
+ rows.c.key.in_(chunk),
483
+ rows.c.search.is_distinct_from(search_vector(config, weights, rows)),
484
+ )
485
+ )
486
+ found.extend(key for (key,) in result)
487
+ return sorted(found)
488
+
489
+ # Derived values -------------------------------------------------------------------
490
+
491
+ async def set_derived(
492
+ self, kind_id: str, attribute_id: str, values: Sequence[tuple[str, Any]]
493
+ ) -> None:
494
+ """Set one derived Attribute of Records by key; a `None` value removes it."""
495
+ await self._before_record_writes(kind_id, [key for key, _ in values])
496
+ statement = (
497
+ update(MAT_RECORDS)
498
+ .where(
499
+ self._scope(MAT_RECORDS),
500
+ MAT_RECORDS.c.kind_id == kind_id,
501
+ MAT_RECORDS.c.key == bindparam("record_key"),
502
+ )
503
+ .values(
504
+ derived=MAT_RECORDS.c.derived.op("-")(
505
+ bindparam("derived_attribute", type_=Text)
506
+ ).op("||")(bindparam("derived_patch", type_=JSONB)),
507
+ **self._stamp(),
508
+ )
509
+ )
510
+ for chunk in chunks(list(values), self._writes.insert_batch_rows):
511
+ await self._session.execute(
512
+ statement,
513
+ [
514
+ {
515
+ "record_key": key,
516
+ "derived_attribute": attribute_id,
517
+ "derived_patch": {} if value is None else {attribute_id: value},
518
+ }
519
+ for key, value in chunk
520
+ ],
521
+ )
522
+
523
+ async def prune_derived(self, kind_id: str, keep: Sequence[str]) -> None:
524
+ """Remove derived values of a Kind's Records except those of the `keep` Attributes."""
525
+ await self._before_writes(
526
+ MAT_RECORDS, lambda s: [s.c.kind_id == kind_id, s.c.derived != literal({}, JSONB)]
527
+ )
528
+ kept = text(
529
+ "COALESCE((SELECT jsonb_object_agg(e.key, e.value) FROM jsonb_each(derived) e "
530
+ "WHERE e.key = ANY(:keep)), '{}'::jsonb)"
531
+ ).bindparams(bindparam("keep", list(keep)))
532
+ await self._session.execute(
533
+ update(MAT_RECORDS)
534
+ .where(
535
+ self._scope(MAT_RECORDS),
536
+ MAT_RECORDS.c.kind_id == kind_id,
537
+ MAT_RECORDS.c.derived != literal({}, JSONB),
538
+ )
539
+ .values(derived=kept, **self._stamp())
540
+ )
541
+
542
+ async def clear_derived(self, keep_kinds: Sequence[str]) -> None:
543
+ """Remove every derived value of Records of Kinds other than `keep_kinds`."""
544
+ await self._before_writes(
545
+ MAT_RECORDS,
546
+ lambda s: [
547
+ s.c.kind_id.not_in(list(keep_kinds)),
548
+ s.c.derived != literal({}, JSONB),
549
+ ],
550
+ )
551
+ await self._session.execute(
552
+ update(MAT_RECORDS)
553
+ .where(
554
+ self._scope(MAT_RECORDS),
555
+ MAT_RECORDS.c.kind_id.not_in(list(keep_kinds)),
556
+ MAT_RECORDS.c.derived != literal({}, JSONB),
557
+ )
558
+ .values(derived=literal({}, JSONB), **self._stamp())
559
+ )
560
+
561
+ async def linked_keys(
562
+ self, relation_id: str, *, near: str, keys: Sequence[str], limit: int | None = None
563
+ ) -> list[tuple[str, str, dict[str, Any]]]:
564
+ """Stored Links of a Relation whose `near` end (`from_key` or `to_key`) is in `keys`:
565
+ `(near key, far key, link values)`; at most `limit` of them when set."""
566
+ links = await self._links()
567
+ near_column = links.c[near]
568
+ far_column = links.c["to_key" if near == "from_key" else "from_key"]
569
+ found: list[tuple[str, str, dict[str, Any]]] = []
570
+ for chunk in chunks(list(dict.fromkeys(keys)), self._writes.insert_batch_rows):
571
+ if limit is not None and len(found) >= limit:
572
+ break
573
+ query = (
574
+ select(near_column, far_column, links.c["values"])
575
+ .where(
576
+ self._scope(links),
577
+ links.c.relation_id == relation_id,
578
+ near_column.in_(chunk),
579
+ )
580
+ .order_by(near_column, far_column)
581
+ )
582
+ if limit is not None:
583
+ query = query.limit(limit - len(found))
584
+ result = await self._session.execute(query)
585
+ found.extend((a, b, dict(v or {})) for a, b, v in result)
586
+ return found
587
+
588
+ async def foreign_key_links(
589
+ self,
590
+ kind_id: str,
591
+ attribute_id: str,
592
+ *,
593
+ keys: Sequence[str],
594
+ by_target: bool,
595
+ limit: int | None = None,
596
+ ) -> list[tuple[str, str]]:
597
+ """`(from key, to key)` of a foreign-key Relation: Records of `kind_id` (the `from`
598
+ Kind) whose key is in `keys`, or whose `attribute_id` value is in `keys` when
599
+ `by_target`; at most `limit` of them when set."""
600
+ rows = await self._records()
601
+ target = rows.c["values"].op("->>")(literal(attribute_id))
602
+ found: list[tuple[str, str]] = []
603
+ for chunk in chunks(list(dict.fromkeys(keys)), self._writes.insert_batch_rows):
604
+ if limit is not None and len(found) >= limit:
605
+ break
606
+ condition = target.in_(chunk) if by_target else rows.c.key.in_(chunk)
607
+ query = (
608
+ select(rows.c.key, target)
609
+ .where(
610
+ self._scope(rows),
611
+ rows.c.kind_id == kind_id,
612
+ target.is_not(None),
613
+ condition,
614
+ )
615
+ .order_by(rows.c.key)
616
+ )
617
+ if limit is not None:
618
+ query = query.limit(limit - len(found))
619
+ result = await self._session.execute(query)
620
+ found.extend((key, value) for key, value in result)
621
+ return found
622
+
623
+ async def stream_records(self, kind_id: str, *, batch: int) -> AsyncIterator[list[RecordRow]]:
624
+ """All Records of a Kind in key order, `batch` rows at a time."""
625
+ last: str | None = None
626
+ while True:
627
+ rows = await self.record_page(kind_id, after=last, limit=batch)
628
+ if not rows:
629
+ return
630
+ yield rows
631
+ last = rows[-1].key
632
+
633
+ async def record_page(self, kind_id: str, *, after: str | None, limit: int) -> list[RecordRow]:
634
+ """Up to `limit` Records of a Kind in key order, after the key `after` when given."""
635
+ rows_from = await self._records()
636
+ query = (
637
+ _record_select(rows_from)
638
+ .where(self._scope(rows_from), rows_from.c.kind_id == kind_id)
639
+ .order_by(rows_from.c.key)
640
+ .limit(limit)
641
+ )
642
+ if after is not None:
643
+ query = query.where(rows_from.c.key > after)
644
+ return [RecordRow(**row) for row in (await self._session.execute(query)).mappings()]
645
+
646
+ async def stream_record_hashes(
647
+ self, kind_id: str, *, batch: int
648
+ ) -> AsyncIterator[list[tuple[str, bytes]]]:
649
+ """`(key, object_hash)` of a Kind's Records in key order."""
650
+ rows_from = await self._records()
651
+ last: str | None = None
652
+ while True:
653
+ query = (
654
+ select(rows_from.c.key, rows_from.c.object_hash)
655
+ .where(self._scope(rows_from), rows_from.c.kind_id == kind_id)
656
+ .order_by(rows_from.c.key)
657
+ .limit(batch)
658
+ )
659
+ if last is not None:
660
+ query = query.where(rows_from.c.key > last)
661
+ rows = [(key, digest) for key, digest in (await self._session.execute(query))]
662
+ if not rows:
663
+ return
664
+ yield rows
665
+ last = rows[-1][0]
666
+
667
+ async def record_keys_with_origin(
668
+ self, kind_id: str, *, origin_type: str, origin_field: str, origin_value: str
669
+ ) -> list[str]:
670
+ """Keys of a Kind's Records whose Lineage names a provenance entry of `origin_type`
671
+ with `origin_ref[origin_field] == origin_value` (for example the Records a Source
672
+ wrote: `source_sync`, `source_id`)."""
673
+ records = (await self.layout()).records_from("r")
674
+ result = await self._session.execute(
675
+ text(_KEYS_WITH_ORIGIN.format(records=records)),
676
+ {
677
+ "domain": self._domain,
678
+ "branch": self._branch,
679
+ "kind": kind_id,
680
+ "origin_type": origin_type,
681
+ "field": origin_field,
682
+ "value": origin_value,
683
+ },
684
+ )
685
+ return [str(key) for key in result.scalars()]
686
+
687
+ async def record_counts(self) -> dict[str, int]:
688
+ """Number of Records per Kind id."""
689
+ rows = await self._records()
690
+ result = await self._session.execute(
691
+ select(rows.c.kind_id, func.count()).where(self._scope(rows)).group_by(rows.c.kind_id)
692
+ )
693
+ return {kind_id: int(count) for kind_id, count in result}
694
+
695
+ # Links --------------------------------------------------------------------------
696
+
697
+ async def links(
698
+ self, relation_id: str, pairs: Iterable[tuple[str, str]]
699
+ ) -> dict[tuple[str, str], LinkRow]:
700
+ rows = await self._links()
701
+ found: dict[tuple[str, str], LinkRow] = {}
702
+ for chunk in chunks(list(dict.fromkeys(pairs)), self._writes.insert_batch_rows):
703
+ result = await self._session.execute(
704
+ _link_select(rows).where(
705
+ self._scope(rows),
706
+ rows.c.relation_id == relation_id,
707
+ pairs_in(rows.c.from_key, rows.c.to_key, chunk),
708
+ )
709
+ )
710
+ for row in result.mappings():
711
+ found[(row["from_key"], row["to_key"])] = LinkRow(**row)
712
+ return found
713
+
714
+ async def upsert_links(self, rows: Sequence[LinkRow]) -> None:
715
+ if not rows:
716
+ return
717
+ by_relation: dict[str, list[tuple[str, str]]] = {}
718
+ for row in rows:
719
+ by_relation.setdefault(row.relation_id, []).append((row.from_key, row.to_key))
720
+ for relation_id, pairs in by_relation.items():
721
+ await self._before_link_writes(relation_id, pairs)
722
+ await bulk_write(
723
+ self._session,
724
+ MAT_LINKS,
725
+ [self._link_values(row) for row in rows],
726
+ writes=self._writes,
727
+ on_conflict=OnConflict.UPDATE,
728
+ update_columns=_LINK_UPDATE_COLUMNS,
729
+ )
730
+
731
+ async def delete_links(self, relation_id: str, pairs: Sequence[tuple[str, str]]) -> None:
732
+ await self._before_link_writes(relation_id, pairs)
733
+ overlay = (await self.layout()).overlay
734
+ for chunk in chunks(pairs, self._writes.insert_batch_rows):
735
+ where = (
736
+ self._scope(MAT_LINKS),
737
+ MAT_LINKS.c.relation_id == relation_id,
738
+ pairs_in(MAT_LINKS.c.from_key, MAT_LINKS.c.to_key, chunk),
739
+ )
740
+ if overlay:
741
+ await self._session.execute(
742
+ update(MAT_LINKS).where(*where).values(deleted=true(), **self._stamp())
743
+ )
744
+ else:
745
+ await self._session.execute(delete(MAT_LINKS).where(*where))
746
+
747
+ async def stream_links(self, relation_id: str, *, batch: int) -> AsyncIterator[list[LinkRow]]:
748
+ """All Links of a Relation in `(from_key, to_key)` order."""
749
+ links = await self._links()
750
+ last: tuple[str, str] | None = None
751
+ while True:
752
+ query = (
753
+ _link_select(links)
754
+ .where(self._scope(links), links.c.relation_id == relation_id)
755
+ .order_by(links.c.from_key, links.c.to_key)
756
+ .limit(batch)
757
+ )
758
+ if last is not None:
759
+ query = query.where(tuple_(links.c.from_key, links.c.to_key) > last)
760
+ rows = [LinkRow(**row) for row in (await self._session.execute(query)).mappings()]
761
+ if not rows:
762
+ return
763
+ yield rows
764
+ last = (rows[-1].from_key, rows[-1].to_key)
765
+
766
+ async def link_counts(self, *, derived: bool = False) -> dict[str, int]:
767
+ """Number of Links per Relation id: stored Links of the Relations of the tree, or with
768
+ `derived` the Links of materialized path Derivations (keyed by Derivation id)."""
769
+ links = await self._links()
770
+ is_derived = links.c.relation_id.startswith(DERIVED_LINK_PREFIX, autoescape=True)
771
+ result = await self._session.execute(
772
+ select(links.c.relation_id, func.count())
773
+ .where(self._scope(links), is_derived if derived else ~is_derived)
774
+ .group_by(links.c.relation_id)
775
+ )
776
+ return {relation_id: int(count) for relation_id, count in result}
777
+
778
+ # History ------------------------------------------------------------------------
779
+
780
+ async def insert_history(self, rows: Sequence[HistoryRow]) -> None:
781
+ await bulk_write(
782
+ self._session,
783
+ MAT_HISTORY,
784
+ [
785
+ {
786
+ "domain_id": self._domain,
787
+ "branch": self._branch,
788
+ "partition": row.partition,
789
+ "element_key": row.element_key,
790
+ "revision_id": row.revision_id,
791
+ "at": row.at,
792
+ "old_hash": row.old_hash,
793
+ "new_hash": row.new_hash,
794
+ "provenance": row.provenance,
795
+ }
796
+ for row in rows
797
+ ],
798
+ writes=self._writes,
799
+ on_conflict=OnConflict.ERROR,
800
+ )
801
+
802
+ async def first_parent_range(
803
+ self, head: bytes, stop: bytes, *, limit: int
804
+ ) -> list[bytes] | None:
805
+ """The Revisions from `head` down the first-parent chain to `stop` (excluded), when
806
+ `stop` is reached within `limit` of them; None otherwise."""
807
+ if limit <= 0 or head == stop:
808
+ return None
809
+ found = await self._session.execute(
810
+ _CHAIN_TO, {"head": head, "stop": stop, "domain": self._domain, "limit": limit}
811
+ )
812
+ ids = [bytes(row[0]) for row in found]
813
+ if ids[-1] != stop:
814
+ return None
815
+ return ids[:-1]
816
+
817
+ async def insert_chain_history(self, revisions: Sequence[bytes]) -> None:
818
+ """History rows of the changesets of `revisions`, as `rebuild_history` writes them."""
819
+ await self._session.execute(
820
+ _INSERT_CHAIN_HISTORY,
821
+ {"domain": self._domain, "branch": self._branch, "ids": list(revisions)},
822
+ )
823
+
824
+ async def history(self, partition: str, element_key: str) -> list[HistoryRow]:
825
+ """Changes of one element on this branch, oldest first."""
826
+ history = (await self.layout()).history()
827
+ result = await self._session.execute(
828
+ select(
829
+ history.c.partition,
830
+ history.c.element_key,
831
+ history.c.revision_id,
832
+ history.c.at,
833
+ history.c.old_hash,
834
+ history.c.new_hash,
835
+ history.c.provenance,
836
+ )
837
+ .join(
838
+ REVISIONS,
839
+ and_(
840
+ REVISIONS.c.domain_id == history.c.domain_id,
841
+ REVISIONS.c.id == history.c.revision_id,
842
+ ),
843
+ )
844
+ .where(
845
+ self._scope(history),
846
+ history.c.partition == partition,
847
+ history.c.element_key == element_key,
848
+ )
849
+ .order_by(REVISIONS.c.generation)
850
+ )
851
+ return [HistoryRow(**row) for row in result.mappings()]
852
+
853
+ async def rebuild_history(self, head: bytes) -> None:
854
+ """Replace this branch's history with the changesets of `head`'s first-parent chain."""
855
+ await self._session.execute(delete(MAT_HISTORY).where(self._scope(MAT_HISTORY)))
856
+ await self._session.execute(
857
+ _REBUILD_HISTORY, {"head": head, "domain": self._domain, "branch": self._branch}
858
+ )
859
+
860
+ async def stream_history_steps(
861
+ self, partition: str, *, batch: int, keys: Sequence[str] | None = None
862
+ ) -> AsyncIterator[list[HistoryStep]]:
863
+ """History of a partition (of its elements in `keys`, when given) ordered by element
864
+ key, then oldest change first."""
865
+ history = (await self.layout()).history()
866
+ query = (
867
+ select(
868
+ history.c.element_key,
869
+ history.c.revision_id,
870
+ history.c.at,
871
+ history.c.new_hash,
872
+ history.c.provenance,
873
+ history.c.old_hash,
874
+ (REVISIONS.c.origin["type"].astext == MERGE_ORIGIN_TYPE).label("merge"),
875
+ )
876
+ .join(
877
+ REVISIONS,
878
+ and_(
879
+ REVISIONS.c.domain_id == history.c.domain_id,
880
+ REVISIONS.c.id == history.c.revision_id,
881
+ ),
882
+ )
883
+ .where(self._scope(history), history.c.partition == partition)
884
+ .order_by(history.c.element_key, REVISIONS.c.generation)
885
+ )
886
+ if keys is not None:
887
+ query = query.where(history.c.element_key.in_(list(keys)))
888
+ result = await self._session.stream(query)
889
+ async for part in result.partitions(batch):
890
+ yield [HistoryStep(*row) for row in part]
891
+
892
+ # Whole branch -------------------------------------------------------------------
893
+
894
+ async def clear(self) -> None:
895
+ """Delete every materialized row of the branch (its own rows, for an overlay)."""
896
+ if await self.dependents():
897
+ raise RuntimeError(
898
+ f"branch {self._branch!r} has overlay branches; promote them before clearing it"
899
+ )
900
+ for target in (MAT_RECORDS, MAT_LINKS, MAT_HISTORY, MAT_SCHEMA):
901
+ await self._session.execute(delete(target).where(self._scope(target)))
902
+
903
+ async def copy_from(self, source: str | BranchLayout) -> None:
904
+ """Copy every materialized row of `source` into this (empty) branch; an overlay source
905
+ gives its resolved rows."""
906
+ layout = (
907
+ source
908
+ if isinstance(source, BranchLayout)
909
+ else await load_layout(self._session, self._domain, source)
910
+ )
911
+ sources = (
912
+ (MAT_SCHEMA, MAT_SCHEMA),
913
+ (MAT_RECORDS, layout.records()),
914
+ (MAT_LINKS, layout.links()),
915
+ (MAT_HISTORY, layout.history()),
916
+ )
917
+ for target, rows in sources:
918
+ names = [c.name for c in target.columns]
919
+ selected = select(
920
+ *(
921
+ literal(self._branch).label("branch") if name == "branch" else rows.c[name]
922
+ for name in names
923
+ )
924
+ ).where(rows.c.domain_id == self._domain, rows.c.branch == layout.branch)
925
+ await self._session.execute(insert(target).from_select(names, selected))
926
+
927
+ async def complete_overlay(self) -> None:
928
+ """Turn this overlay's rows into a full head: copy the base rows it reads (as of its
929
+ fork point), drop its tombstones and copy the base history up to the fork."""
930
+ layout = await self.layout()
931
+ if not layout.overlay:
932
+ return
933
+ for table in (MAT_RECORDS, MAT_LINKS):
934
+ await self._copy(table, layout, lambda _: ())
935
+ await self._session.execute(
936
+ delete(table).where(self._scope(table), table.c.deleted.is_(true()))
937
+ )
938
+ names = [c.name for c in MAT_HISTORY.columns]
939
+ history = layout.history()
940
+ await self._session.execute(
941
+ pg_insert(MAT_HISTORY)
942
+ .from_select(
943
+ names,
944
+ select(*(history.c[name] for name in names)).where(
945
+ history.c.branch == self._branch,
946
+ history.c.domain_id == self._domain,
947
+ ),
948
+ )
949
+ .on_conflict_do_nothing()
950
+ )
951
+
952
+ async def own_rows(self, *, limit: int) -> int:
953
+ """Rows (Records, Links, tombstones) stored for this branch, counted up to `limit`."""
954
+ total = 0
955
+ for table in (MAT_RECORDS, MAT_LINKS):
956
+ bounded: Select[Any] = (
957
+ select(literal_column("1")).where(self._scope(table)).limit(limit + 1)
958
+ )
959
+ total += int(
960
+ await self._session.scalar(select(func.count()).select_from(bounded.subquery()))
961
+ or 0
962
+ )
963
+ return total
964
+
965
+ # Overlay bookkeeping ------------------------------------------------------------
966
+
967
+ def _stamp(self) -> dict[str, Any]:
968
+ """`revision_generation` of a write, when the writing Revision is known."""
969
+ return {} if self._generation is None else {"revision_generation": self._generation}
970
+
971
+ async def _before_record_writes(self, kind_id: str, keys: Sequence[str]) -> None:
972
+ for chunk in chunks(list(dict.fromkeys(keys)), self._writes.insert_batch_rows):
973
+ await self._before_writes(MAT_RECORDS, _record_keys(kind_id, chunk))
974
+
975
+ async def _before_link_writes(self, relation_id: str, pairs: Sequence[tuple[str, str]]) -> None:
976
+ for chunk in chunks(list(dict.fromkeys(pairs)), self._writes.insert_batch_rows):
977
+ await self._before_writes(MAT_LINKS, _link_keys(relation_id, chunk))
978
+
979
+ async def _before_writes(self, table: Table, where: Where) -> None:
980
+ """Preserve the rows matching `where` before they change (see the class doc)."""
981
+ layout = await self.layout()
982
+ if layout.overlay:
983
+ await self._copy(table, layout, where)
984
+ return
985
+ dependents = await self.dependents()
986
+ if not dependents:
987
+ return
988
+ if self._generation is None:
989
+ raise RuntimeError(
990
+ f"writing branch {self._branch!r}, which has overlay branches, needs the "
991
+ "generation of the writing Revision"
992
+ )
993
+ source = table.alias("s")
994
+ targets = (
995
+ func.unnest(
996
+ bindparam("cow_names", [name for name, _ in dependents], type_=ARRAY(Text())),
997
+ bindparam(
998
+ "cow_generations", [gen for _, gen in dependents], type_=ARRAY(BigInteger())
999
+ ),
1000
+ )
1001
+ .table_valued("name", "generation")
1002
+ .render_derived(name="d")
1003
+ )
1004
+ names = [c.name for c in table.columns]
1005
+ selected = (
1006
+ select(*(_copied(source, name, targets.c.name) for name in names))
1007
+ .select_from(source.join(targets, source.c.revision_generation <= targets.c.generation))
1008
+ .where(
1009
+ source.c.domain_id == self._domain,
1010
+ source.c.branch == self._branch,
1011
+ *where(source),
1012
+ )
1013
+ )
1014
+ await self._session.execute(
1015
+ pg_insert(table).from_select(names, selected).on_conflict_do_nothing()
1016
+ )
1017
+
1018
+ async def _copy(self, table: Table, layout: BranchLayout, where: Where) -> None:
1019
+ """Copy the base rows an overlay reads (those matching `where`) into it, keeping the
1020
+ rows it already holds."""
1021
+ assert layout.base is not None
1022
+ assert layout.base_generation is not None
1023
+ source = table.alias("s")
1024
+ names = [c.name for c in table.columns]
1025
+ selected = select(*(_copied(source, name, literal(self._branch)) for name in names)).where(
1026
+ source.c.domain_id == self._domain,
1027
+ source.c.branch == layout.base,
1028
+ source.c.revision_generation <= layout.base_generation,
1029
+ *where(source),
1030
+ )
1031
+ await self._session.execute(
1032
+ pg_insert(table).from_select(names, selected).on_conflict_do_nothing()
1033
+ )
1034
+
1035
+ async def _revive_records(self, kind_id: str, keys: Sequence[str]) -> None:
1036
+ """Tombstones about to be written again start like new rows (no derived values)."""
1037
+ for chunk in chunks(list(dict.fromkeys(keys)), self._writes.insert_batch_rows):
1038
+ await self._session.execute(
1039
+ update(MAT_RECORDS)
1040
+ .where(
1041
+ self._scope(MAT_RECORDS),
1042
+ MAT_RECORDS.c.kind_id == kind_id,
1043
+ MAT_RECORDS.c.key.in_(chunk),
1044
+ MAT_RECORDS.c.deleted.is_(true()),
1045
+ )
1046
+ .values(derived=literal({}, JSONB), search=None)
1047
+ )
1048
+
1049
+ # Row mapping --------------------------------------------------------------------
1050
+
1051
+ def _record_values(self, row: RecordRow) -> dict[str, Any]:
1052
+ return {
1053
+ "domain_id": self._domain,
1054
+ "branch": self._branch,
1055
+ "kind_id": row.kind_id,
1056
+ "key": row.key,
1057
+ "values": row.values,
1058
+ "title": row.title,
1059
+ "object_hash": row.object_hash,
1060
+ "lineage": row.lineage,
1061
+ "updated_revision": row.updated_revision,
1062
+ "updated_at": row.updated_at,
1063
+ "revision_generation": self._generation or 0,
1064
+ "deleted": False,
1065
+ "lineage_revisions": row.lineage_revisions,
1066
+ }
1067
+
1068
+ def _link_values(self, row: LinkRow) -> dict[str, Any]:
1069
+ return {
1070
+ "domain_id": self._domain,
1071
+ "branch": self._branch,
1072
+ "relation_id": row.relation_id,
1073
+ "from_key": row.from_key,
1074
+ "to_key": row.to_key,
1075
+ "values": row.values,
1076
+ "object_hash": row.object_hash,
1077
+ "lineage": row.lineage,
1078
+ "revision_generation": self._generation or 0,
1079
+ "deleted": False,
1080
+ }
1081
+
1082
+
1083
+ def _record_select(rows: FromClause) -> Any:
1084
+ c = rows.c
1085
+ return select(
1086
+ c.kind_id,
1087
+ c.key,
1088
+ c["values"],
1089
+ c.title,
1090
+ c.object_hash,
1091
+ lineage_column(rows),
1092
+ c.updated_revision,
1093
+ c.updated_at,
1094
+ c.derived,
1095
+ lineage_column(rows, "lineage_revisions"),
1096
+ )
1097
+
1098
+
1099
+ def _link_select(rows: FromClause) -> Any:
1100
+ c = rows.c
1101
+ return select(
1102
+ c.relation_id, c.from_key, c.to_key, c["values"], c.object_hash, lineage_column(rows)
1103
+ )
1104
+
1105
+
1106
+ def _record_keys(kind_id: str, keys: Sequence[str]) -> Where:
1107
+ def where(rows: FromClause) -> list[ColumnElement[bool]]:
1108
+ return [rows.c.kind_id == kind_id, rows.c.key.in_(keys)]
1109
+
1110
+ return where
1111
+
1112
+
1113
+ def _link_keys(relation_id: str, pairs: Sequence[tuple[str, str]]) -> Where:
1114
+ def where(rows: FromClause) -> list[ColumnElement[bool]]:
1115
+ return [
1116
+ rows.c.relation_id == relation_id,
1117
+ pairs_in(rows.c.from_key, rows.c.to_key, pairs),
1118
+ ]
1119
+
1120
+ return where
1121
+
1122
+
1123
+ def _copied(source: FromClause, name: str, branch: ColumnElement[Any]) -> ColumnElement[Any]:
1124
+ """Column `name` of a row copied from `source` into another branch."""
1125
+ if name == "branch":
1126
+ return branch.label("branch")
1127
+ if name == "deleted":
1128
+ return false().label("deleted")
1129
+ return source.c[name]