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,536 @@
1
+ """Batches of a branch built from its tree, and of a schema change reconciled after a copy
2
+ (`materialize.background`).
3
+
4
+ Each function runs one batch in the caller's transaction and returns the rows it handled
5
+ with the cursor of the next batch (`None`: the phase is done). The caller holds the branch
6
+ lock. They write the branch's own rows (`full_layout`): a branch being built is not read
7
+ through its materialized rows meanwhile, and a reconciled branch is full.
8
+
9
+ A tree build does in pieces what `rebuild.materialize_revision` does in one transaction:
10
+
11
+ - `clear_batch`: deletes rows the branch held before (an overlay detached from its base);
12
+ - `history_batch`: the changesets of the head's first-parent chain, a few Revisions at a
13
+ time (`depth` from the head), then the schema row;
14
+ - `rows_batch`: Records and Links of the head's tree, whole leaves at a time
15
+ (`NodeRepository.scan_from`), with lineage folded from the history, titles and search
16
+ vectors;
17
+ - `derived_batch` and `paths_batch`: every derived value, Derivation by Derivation, and the
18
+ pairs of the materialized paths, start key by start key.
19
+
20
+ A schema change made while an overlay was copied (`apply.apply_changeset`) is reconciled
21
+ after the switch with what a schema change recomputes on a full branch: `prune_batch` drops
22
+ derived values and path Links the schema no longer has, then `derived_batch`, `paths_batch`
23
+ and `search_batch` (titles and search vectors of the Kinds whose settings changed).
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from collections import defaultdict
29
+ from collections.abc import Sequence
30
+ from dataclasses import dataclass
31
+ from typing import Any
32
+
33
+ from sqlalchemy import bindparam, text
34
+ from sqlalchemy.dialects.postgresql import JSONB
35
+ from sqlalchemy.ext.asyncio import AsyncSession
36
+
37
+ from lorebase_core.expr import ExprLimits
38
+ from lorebase_core.expr.sensitivity import with_tags
39
+ from lorebase_core.model import Schema
40
+ from lorebase_store.bulk import chunks
41
+ from lorebase_store.layout import MAT_HISTORY, MAT_LINKS, MAT_RECORDS, full_layout
42
+ from lorebase_store.materialize.derived import DerivedValues, plan_derivations
43
+ from lorebase_store.materialize.paths import PathLinks
44
+ from lorebase_store.materialize.rebuild import fold_history, link_row, load_schema, record_row
45
+ from lorebase_store.materialize.rows import (
46
+ PartitionType,
47
+ parse_partition,
48
+ record_title,
49
+ search_weights,
50
+ )
51
+ from lorebase_store.materialize.sensitivity import reference_tags
52
+ from lorebase_store.nodestore import NodeRepository
53
+ from lorebase_store.objectstore import PgObjectStore
54
+ from lorebase_store.repositories.materialized import DERIVED_LINK_PREFIX, SchemaRow
55
+ from lorebase_store.repositories.materialized import MaterializedRepository as Rows
56
+ from lorebase_store.repositories.revisions import RevisionRow
57
+ from lorebase_store.runtime import StoreRuntime
58
+
59
+ Cursor = list[Any] | None
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class Batch:
64
+ """What one batch did: rows handled and where the next one starts (`None`: done)."""
65
+
66
+ rows: int
67
+ cursor: Cursor
68
+
69
+
70
+ def own_rows(
71
+ session: AsyncSession,
72
+ domain_id: str,
73
+ branch: str,
74
+ runtime: StoreRuntime,
75
+ *,
76
+ generation: int | None = None,
77
+ ) -> Rows:
78
+ """The branch's own rows, whatever its ref says (`full_layout`)."""
79
+ return Rows(
80
+ session,
81
+ domain_id,
82
+ branch,
83
+ writes=runtime.settings.writes,
84
+ layout=full_layout(domain_id, branch),
85
+ generation=generation,
86
+ )
87
+
88
+
89
+ async def branch_schema(rows: Rows) -> Schema | None:
90
+ """The schema stored for the branch, if any."""
91
+ stored = await rows.schema()
92
+ if stored is None:
93
+ return None
94
+ # Imported here: the commit package imports the materializer.
95
+ from lorebase_store.commit.schema import load_compiled
96
+
97
+ return load_compiled(stored.compiled)
98
+
99
+
100
+ # Tree build ---------------------------------------------------------------------------------
101
+
102
+
103
+ async def clear_batch(session: AsyncSession, domain_id: str, branch: str, *, limit: int) -> Batch:
104
+ """Delete up to `limit` rows the branch holds (Records, Links, history), table by
105
+ table; done when none is left."""
106
+ for table in (MAT_RECORDS, MAT_LINKS, MAT_HISTORY):
107
+ key = ", ".join(c.name for c in table.primary_key.columns)
108
+ deleted = await session.execute(
109
+ text(
110
+ f"DELETE FROM {table.name} WHERE ({key}) IN (SELECT {key} FROM {table.name} "
111
+ "WHERE domain_id = :domain AND branch = :branch LIMIT :limit)"
112
+ ),
113
+ {"domain": domain_id, "branch": branch, "limit": limit},
114
+ )
115
+ count = int(getattr(deleted, "rowcount", 0) or 0)
116
+ if count:
117
+ return Batch(count, [])
118
+ return Batch(0, None)
119
+
120
+
121
+ _CHAIN_HISTORY = text(
122
+ """
123
+ WITH RECURSIVE chain(id, depth) AS (
124
+ SELECT CAST(:head AS bytea), 0
125
+ UNION ALL
126
+ SELECT p.parent_id, chain.depth + 1
127
+ FROM chain
128
+ JOIN revision_parents p
129
+ ON p.domain_id = :domain AND p.revision_id = chain.id AND p.position = 0
130
+ ),
131
+ sized AS (
132
+ SELECT chain.id, chain.depth, counted.entries,
133
+ sum(counted.entries) OVER (ORDER BY chain.depth) AS upto
134
+ FROM chain
135
+ CROSS JOIN LATERAL (
136
+ SELECT count(*) AS entries FROM changesets c
137
+ WHERE c.domain_id = :domain AND c.revision_id = chain.id
138
+ ) counted
139
+ WHERE chain.depth >= :from_depth
140
+ ),
141
+ picked AS (
142
+ SELECT id, depth FROM sized WHERE upto - entries < :limit
143
+ ),
144
+ inserted AS (
145
+ INSERT INTO mat_history
146
+ (domain_id, branch, partition, element_key, revision_id, at, old_hash, new_hash,
147
+ provenance)
148
+ SELECT :domain, :branch, c.partition, c.element_key, c.revision_id, r.created_at,
149
+ c.old_hash, c.new_hash, c.provenance
150
+ FROM picked
151
+ JOIN revisions r ON r.domain_id = :domain AND r.id = picked.id
152
+ JOIN changesets c ON c.domain_id = :domain AND c.revision_id = picked.id
153
+ ON CONFLICT DO NOTHING
154
+ RETURNING 1
155
+ )
156
+ SELECT (SELECT count(*) FROM inserted), (SELECT max(depth) FROM picked)
157
+ """
158
+ )
159
+
160
+
161
+ async def history_batch(
162
+ session: AsyncSession,
163
+ domain_id: str,
164
+ branch: str,
165
+ head: RevisionRow,
166
+ cursor: Cursor,
167
+ *,
168
+ limit: int,
169
+ runtime: StoreRuntime,
170
+ ) -> Batch:
171
+ """Copy the changesets of the next Revisions of the head's first-parent chain (cursor:
172
+ `[depth]`, 0 being the head) into the branch history, about `limit` entries (at least one
173
+ Revision). When none is left, store the schema of the head's tree."""
174
+ depth = 0 if not cursor else int(cursor[0])
175
+ found = (
176
+ await session.execute(
177
+ _CHAIN_HISTORY,
178
+ {
179
+ "head": head.id,
180
+ "domain": domain_id,
181
+ "branch": branch,
182
+ "from_depth": depth,
183
+ "limit": limit,
184
+ },
185
+ )
186
+ ).one()
187
+ inserted, last = int(found[0] or 0), found[1]
188
+ if last is not None:
189
+ return Batch(inserted, [int(last) + 1])
190
+ settings = runtime.settings
191
+ nodes = NodeRepository(
192
+ session, domain_id, writes=settings.writes, batch=settings.nodes.prefetch_batch
193
+ )
194
+ tree = await nodes.load_tree(head.root)
195
+ schema, schema_hash = await load_schema(session, domain_id, tree, runtime=runtime)
196
+ # Imported here: the commit package imports the materializer.
197
+ from lorebase_store.commit.schema import compile_schema
198
+
199
+ await own_rows(session, domain_id, branch, runtime).set_schema(
200
+ SchemaRow(schema_hash, compile_schema(schema))
201
+ )
202
+ return Batch(0, None)
203
+
204
+
205
+ async def rows_batch(
206
+ session: AsyncSession,
207
+ domain_id: str,
208
+ branch: str,
209
+ head: RevisionRow,
210
+ cursor: Cursor,
211
+ *,
212
+ limit: int,
213
+ runtime: StoreRuntime,
214
+ ) -> Batch:
215
+ """Write the next Records or Links of the head's tree, whole leaves of one partition at
216
+ a time (cursor: `[partition, leaf path]`)."""
217
+ settings = runtime.settings
218
+ read_batch = settings.materialize.read_batch_rows
219
+ nodes = NodeRepository(
220
+ session, domain_id, writes=settings.writes, batch=settings.nodes.prefetch_batch
221
+ )
222
+ tree = await nodes.load_tree(head.root)
223
+ partitions = [
224
+ p for p in sorted(tree.partitions) if parse_partition(p).type is not PartitionType.SCHEMA
225
+ ]
226
+ if cursor:
227
+ partition, after = str(cursor[0]), cursor[1]
228
+ elif partitions:
229
+ partition, after = partitions[0], None
230
+ else:
231
+ return Batch(0, None)
232
+ rows = own_rows(session, domain_id, branch, runtime, generation=head.generation)
233
+ schema = await branch_schema(rows)
234
+ if schema is None:
235
+ raise RuntimeError(f"branch {branch!r} has no schema row: its history is not built")
236
+ tagged = with_tags(schema, await reference_tags(session, domain_id, branch, runtime=runtime))
237
+ objects = PgObjectStore(session, domain_id, writes=settings.writes, batch=read_batch)
238
+ entries, last = await nodes.scan_from(tree.partitions[partition].root, after=after, limit=limit)
239
+ parsed = parse_partition(partition)
240
+ hashes = dict(entries)
241
+ keys = sorted(hashes)
242
+ folded = (
243
+ await fold_history(rows, partition, batch=read_batch, objects=objects, keys=keys)
244
+ if keys
245
+ else {}
246
+ )
247
+ for chunk in chunks(keys, read_batch):
248
+ bodies = await objects.get_many(hashes[key] for key in chunk)
249
+ values = {key: dict(bodies[hashes[key]].body.get("values", {})) for key in chunk}
250
+ if parsed.type is PartitionType.KIND:
251
+ await rows.upsert_records(
252
+ [
253
+ record_row(
254
+ parsed.element_id,
255
+ key,
256
+ values[key],
257
+ hashes[key],
258
+ tagged,
259
+ folded.get(key),
260
+ head,
261
+ )
262
+ for key in chunk
263
+ ]
264
+ )
265
+ await rows.refresh_search(
266
+ parsed.element_id,
267
+ search_weights(tagged.kinds.get(parsed.element_id)),
268
+ config=settings.materialize.text_search_config,
269
+ keys=chunk,
270
+ )
271
+ else:
272
+ await rows.upsert_links(
273
+ [
274
+ link_row(parsed.element_id, key, values[key], hashes[key], folded.get(key))
275
+ for key in chunk
276
+ ]
277
+ )
278
+ if last is not None:
279
+ return Batch(len(keys), [partition, last])
280
+ following = [p for p in partitions if p > partition]
281
+ return Batch(len(keys), [following[0], None] if following else None)
282
+
283
+
284
+ # Derived values and paths (tree build and reconciliation) -----------------------------------
285
+
286
+
287
+ async def _next_keys(
288
+ session: AsyncSession,
289
+ domain_id: str,
290
+ branch: str,
291
+ kind_id: str,
292
+ after: str | None,
293
+ limit: int,
294
+ *,
295
+ path_id: str | None = None,
296
+ ) -> list[str]:
297
+ """The next `limit` keys of a Kind's Records after `after`, with `path_id` also the
298
+ start keys stored for that path (a start Kind changed by a schema change leaves them)."""
299
+ params: dict[str, Any] = {"domain": domain_id, "branch": branch, "kind": kind_id}
300
+ params["limit"] = limit
301
+ # Separate statements with and without a start: a range condition the index serves.
302
+ records = (
303
+ "SELECT key FROM mat_records WHERE domain_id = :domain AND branch = :branch "
304
+ "AND kind_id = :kind"
305
+ )
306
+ if after is not None:
307
+ params["after"] = after
308
+ records += " AND key > :after"
309
+ if path_id is None:
310
+ sql = f"{records} ORDER BY key LIMIT :limit"
311
+ else:
312
+ params["path"] = path_id
313
+ stored = (
314
+ "SELECT from_key FROM mat_links WHERE domain_id = :domain AND branch = :branch "
315
+ "AND relation_id = :path"
316
+ )
317
+ if after is not None:
318
+ stored += " AND from_key > :after"
319
+ sql = (
320
+ f"SELECT key FROM (({records} ORDER BY key LIMIT :limit) UNION "
321
+ f"({stored} ORDER BY from_key LIMIT :limit)) k ORDER BY key LIMIT :limit"
322
+ )
323
+ return [str(key) for (key,) in await session.execute(text(sql), params)]
324
+
325
+
326
+ async def derived_batch(
327
+ session: AsyncSession,
328
+ domain_id: str,
329
+ branch: str,
330
+ head: RevisionRow,
331
+ cursor: Cursor,
332
+ *,
333
+ limit: int,
334
+ runtime: StoreRuntime,
335
+ expression_limits: ExprLimits,
336
+ ) -> Batch:
337
+ """Recompute one Derivation for the next `limit` Records of its Kind (cursor: `[step,
338
+ last key]`, steps in evaluation order), and the search vectors whose values changed."""
339
+ settings = runtime.settings
340
+ if not settings.materialize.derivations_enabled:
341
+ return Batch(0, None)
342
+ rows = own_rows(session, domain_id, branch, runtime)
343
+ schema = await branch_schema(rows)
344
+ if schema is None:
345
+ return Batch(0, None)
346
+ derived = DerivedValues(
347
+ rows,
348
+ schema,
349
+ limits=expression_limits,
350
+ now=head.created_at,
351
+ batch=settings.materialize.read_batch_rows,
352
+ max_incremental=settings.materialize.derivation_max_incremental_records,
353
+ fanout=settings.traverse.path_fanout,
354
+ )
355
+ index, after = (0, None) if not cursor else (int(cursor[0]), cursor[1])
356
+ while index < len(derived.steps):
357
+ step = derived.steps[index]
358
+ keys = await _next_keys(session, domain_id, branch, step.kind.id, after, limit)
359
+ if not keys:
360
+ index, after = index + 1, None
361
+ continue
362
+ changed = await derived.recompute_step(step, keys)
363
+ if changed and step.attribute.searchable:
364
+ tagged = with_tags(
365
+ schema, await reference_tags(session, domain_id, branch, runtime=runtime)
366
+ )
367
+ await rows.refresh_search(
368
+ step.kind.id,
369
+ search_weights(tagged.kinds.get(step.kind.id)),
370
+ config=settings.materialize.text_search_config,
371
+ keys=sorted(changed),
372
+ )
373
+ return Batch(len(keys), [index, keys[-1]])
374
+ return Batch(0, None)
375
+
376
+
377
+ async def paths_batch(
378
+ session: AsyncSession,
379
+ domain_id: str,
380
+ branch: str,
381
+ cursor: Cursor,
382
+ *,
383
+ limit: int,
384
+ runtime: StoreRuntime,
385
+ ) -> Batch:
386
+ """Recompute the pairs of one materialized path for its next `limit` start keys
387
+ (cursor: `[path, last key]`)."""
388
+ settings = runtime.settings
389
+ if not settings.materialize.derivations_enabled:
390
+ return Batch(0, None)
391
+ rows = own_rows(session, domain_id, branch, runtime)
392
+ schema = await branch_schema(rows)
393
+ if schema is None:
394
+ return Batch(0, None)
395
+ paths = PathLinks(
396
+ rows,
397
+ schema,
398
+ fanout=settings.traverse.path_fanout,
399
+ batch=settings.materialize.read_batch_rows,
400
+ max_incremental=settings.materialize.derivation_max_incremental_records,
401
+ )
402
+ index, after = (0, None) if not cursor else (int(cursor[0]), cursor[1])
403
+ while index < len(paths.paths):
404
+ path = paths.paths[index]
405
+ starts = await _next_keys(
406
+ session, domain_id, branch, path.relation.from_kind, after, limit, path_id=path.id
407
+ )
408
+ if not starts:
409
+ index, after = index + 1, None
410
+ continue
411
+ await paths.refresh(path, starts)
412
+ return Batch(len(starts), [index, starts[-1]])
413
+ # Every path is computed with the current fan-out: commits need not recompute them.
414
+ await paths.record_fanout()
415
+ return Batch(0, None)
416
+
417
+
418
+ # Reconciliation -----------------------------------------------------------------------------
419
+
420
+
421
+ _DERIVED_ROWS = (
422
+ "SELECT kind_id, key, derived FROM mat_records "
423
+ "WHERE domain_id = :domain AND branch = :branch AND derived <> CAST('{{}}' AS jsonb){after} "
424
+ "ORDER BY kind_id, key LIMIT :limit"
425
+ )
426
+ _SET_DERIVED = text(
427
+ "UPDATE mat_records SET derived = :derived WHERE domain_id = :domain AND branch = :branch "
428
+ "AND kind_id = :kind AND key = :key"
429
+ ).bindparams(bindparam("derived", type_=JSONB))
430
+ _STALE_PATH_LINKS = text(
431
+ "DELETE FROM mat_links WHERE (domain_id, branch, relation_id, from_key, to_key) IN ("
432
+ "SELECT domain_id, branch, relation_id, from_key, to_key FROM mat_links "
433
+ "WHERE domain_id = :domain AND branch = :branch AND starts_with(relation_id, :prefix) "
434
+ "AND NOT (relation_id = ANY(CAST(:keep AS text[]))) LIMIT :limit)"
435
+ )
436
+
437
+
438
+ async def prune_batch(
439
+ session: AsyncSession,
440
+ domain_id: str,
441
+ branch: str,
442
+ cursor: Cursor,
443
+ *,
444
+ limit: int,
445
+ runtime: StoreRuntime,
446
+ expression_limits: ExprLimits,
447
+ ) -> Batch:
448
+ """Drop derived values of Attributes that are no longer derived from the next `limit`
449
+ Records holding any (cursor: `["records", kind, key]`), then Links of path Derivations
450
+ that are gone or no longer materialized (`["links"]`)."""
451
+ rows = own_rows(session, domain_id, branch, runtime)
452
+ schema = await branch_schema(rows)
453
+ if schema is None:
454
+ return Batch(0, None)
455
+ if not cursor or cursor[0] == "records":
456
+ keep: dict[str, set[str]] = defaultdict(set)
457
+ if runtime.settings.materialize.derivations_enabled:
458
+ for step in plan_derivations(schema, expression_limits):
459
+ keep[step.kind.id].add(step.attribute.id)
460
+ params: dict[str, Any] = {"domain": domain_id, "branch": branch, "limit": limit}
461
+ after = ""
462
+ if cursor:
463
+ params.update(kind=cursor[1], key=cursor[2])
464
+ after = " AND (kind_id, key) > (:kind, :key)"
465
+ found = (await session.execute(text(_DERIVED_ROWS.format(after=after)), params)).all()
466
+ if found:
467
+ for kind_id, record_key, values in found:
468
+ kept = {k: v for k, v in dict(values).items() if k in keep.get(kind_id, ())}
469
+ if kept != dict(values):
470
+ await session.execute(
471
+ _SET_DERIVED,
472
+ {
473
+ "derived": kept,
474
+ "domain": domain_id,
475
+ "branch": branch,
476
+ "kind": kind_id,
477
+ "key": record_key,
478
+ },
479
+ )
480
+ last_kind, last_key, _ = found[-1]
481
+ return Batch(len(found), ["records", last_kind, last_key])
482
+ materialized = sorted(p.id for p in schema.path_relations().values() if p.materialized)
483
+ if not runtime.settings.materialize.derivations_enabled:
484
+ materialized = []
485
+ deleted = await session.execute(
486
+ _STALE_PATH_LINKS,
487
+ {
488
+ "domain": domain_id,
489
+ "branch": branch,
490
+ "prefix": DERIVED_LINK_PREFIX,
491
+ "keep": materialized,
492
+ "limit": limit,
493
+ },
494
+ )
495
+ count = int(getattr(deleted, "rowcount", 0) or 0)
496
+ return Batch(count, ["links"] if count else None)
497
+
498
+
499
+ async def search_batch(
500
+ session: AsyncSession,
501
+ domain_id: str,
502
+ branch: str,
503
+ refresh_kinds: Sequence[str],
504
+ cursor: Cursor,
505
+ *,
506
+ limit: int,
507
+ runtime: StoreRuntime,
508
+ ) -> Batch:
509
+ """Recompute titles and search vectors of the next `limit` Records of the Kinds in
510
+ `refresh_kinds` (cursor: `[kind index, last key]`)."""
511
+ rows = own_rows(session, domain_id, branch, runtime)
512
+ schema = await branch_schema(rows)
513
+ if schema is None:
514
+ return Batch(0, None)
515
+ tagged = with_tags(schema, await reference_tags(session, domain_id, branch, runtime=runtime))
516
+ kinds = sorted(refresh_kinds)
517
+ index, after = (0, None) if not cursor else (int(cursor[0]), cursor[1])
518
+ while index < len(kinds):
519
+ kind_id = kinds[index]
520
+ keys = await _next_keys(session, domain_id, branch, kind_id, after, limit)
521
+ if not keys:
522
+ index, after = index + 1, None
523
+ continue
524
+ kind = tagged.kinds.get(kind_id)
525
+ found = await rows.records(kind_id, keys)
526
+ await rows.update_titles(
527
+ kind_id, [(key, record_title(kind, row.values)) for key, row in sorted(found.items())]
528
+ )
529
+ await rows.refresh_search(
530
+ kind_id,
531
+ search_weights(kind),
532
+ config=runtime.settings.materialize.text_search_config,
533
+ keys=keys,
534
+ )
535
+ return Batch(len(keys), [index, keys[-1]])
536
+ return Batch(0, None)