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,72 @@
1
+ """Keys derived from one configured secret, one per namespace and purpose.
2
+
3
+ HKDF-SHA256 turns the secret into independent 256-bit keys (info `lorebase/<namespace>/<purpose>`),
4
+ so a value sealed for one purpose never opens for another. Sealing is AES-256-GCM with a random
5
+ 96-bit nonce and the purpose (plus an optional context) as associated data; MACs are
6
+ HMAC-SHA256. Keeper's web sessions (`keeper.auth.sessions.encryption_key`, namespace `keeper`),
7
+ webhook signing keys at rest (`webhooks.encryption_key`, namespace `webhooks`) and the
8
+ personal data vault (`vault.encryption_key` and `vault.token_key`, namespace `vault`) use it.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import hmac
15
+ import os
16
+
17
+ from cryptography.exceptions import InvalidTag
18
+ from cryptography.hazmat.primitives import hashes
19
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
20
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
21
+
22
+ _NONCE_BYTES = 12
23
+
24
+
25
+ class Keyring:
26
+ """Seals, opens and authenticates values under keys derived from one secret."""
27
+
28
+ def __init__(self, secret: str, *, namespace: str) -> None:
29
+ self._secret = secret.encode()
30
+ self._info = f"lorebase/{namespace}/".encode()
31
+ self._keys: dict[str, bytes] = {}
32
+
33
+ def _key(self, purpose: str) -> bytes:
34
+ key = self._keys.get(purpose)
35
+ if key is None:
36
+ key = HKDF(
37
+ algorithm=hashes.SHA256(),
38
+ length=32,
39
+ salt=None,
40
+ info=self._info + purpose.encode(),
41
+ ).derive(self._secret)
42
+ self._keys[purpose] = key
43
+ return key
44
+
45
+ def seal(self, purpose: str, plaintext: bytes, *, context: bytes = b"") -> bytes:
46
+ """Encrypt and authenticate `plaintext` for `purpose`. A non-empty `context` (such as
47
+ the row a value is stored in) is authenticated too: the value opens only with it."""
48
+ nonce = os.urandom(_NONCE_BYTES)
49
+ aad = _associated(purpose, context)
50
+ return nonce + AESGCM(self._key(purpose)).encrypt(nonce, plaintext, aad)
51
+
52
+ def open(self, purpose: str, sealed: bytes, *, context: bytes = b"") -> bytes | None:
53
+ """The plaintext of a value sealed for `purpose` (and `context`); None when it was
54
+ altered, sealed for another purpose or context, or under another secret."""
55
+ if len(sealed) <= _NONCE_BYTES:
56
+ return None
57
+ nonce, ciphertext = sealed[:_NONCE_BYTES], sealed[_NONCE_BYTES:]
58
+ try:
59
+ return AESGCM(self._key(purpose)).decrypt(
60
+ nonce, ciphertext, _associated(purpose, context)
61
+ )
62
+ except InvalidTag:
63
+ return None
64
+
65
+ def mac(self, purpose: str, message: bytes) -> bytes:
66
+ """HMAC-SHA256 of `message` under the key of `purpose`."""
67
+ return hmac.new(self._key(purpose), message, hashlib.sha256).digest()
68
+
69
+
70
+ def _associated(purpose: str, context: bytes) -> bytes:
71
+ """Associated data: the purpose alone (as before contexts existed), or purpose and context."""
72
+ return purpose.encode() if not context else purpose.encode() + b"\x00" + context
@@ -0,0 +1,335 @@
1
+ """Where the materialized rows of a branch live: its own rows, or an overlay on a base branch.
2
+
3
+ A `full` branch has every row of its head in `mat_records`, `mat_links` and `mat_history`.
4
+ An `overlay` branch (design 6.11, AC-16) stores only the rows it changed, tombstones
5
+ (`deleted`) included, and reads the rows of its base branch as they were at the fork point:
6
+
7
+ overlay rows (not deleted)
8
+ UNION ALL
9
+ base rows with revision_generation <= base_generation that the overlay does not shadow
10
+
11
+ A base commit that changes a row an overlay still reads copies the previous row into the
12
+ overlay first (copy on write, `MaterializedRepository`), so the base rows an overlay reads
13
+ never changed after its fork; the generation filter hides the rows the base wrote since.
14
+
15
+ This module renders that row set once, as SQL text with the Domain, branch, base branch and
16
+ generation as literals (per-Attribute partial indexes of the base match, see
17
+ `lorebase_store.indexes`), and offers it both as text (`records_from`) and as a SQLAlchemy
18
+ `FromClause` with the table's columns (`records`). Every row of the set reports the overlay
19
+ as its `branch`, so predicates written for a table (`branch = :branch`) keep working. For a
20
+ full branch both return the plain table, so its statements are unchanged.
21
+
22
+ The base half also checks that the ref is still an overlay forked at that generation: a
23
+ reader that resolved the layout before a promotion or a re-fork (READ COMMITTED) then reads
24
+ only the overlay rows, which a promotion has completed. Revision `0010` renders the same text
25
+ inside the security definer read functions (`lb_overlay_rows`).
26
+
27
+ For lookups by identity (`records_lookup`, `links_lookup`) one index scan reads both
28
+ branches and the overlay's row of an identity wins; revision `0012` renders the same text
29
+ (`lb_overlay_lookup`).
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from dataclasses import dataclass, field
35
+ from typing import Any
36
+
37
+ from sqlalchemy import FromClause, Label, Table, func, text
38
+ from sqlalchemy.dialects.postgresql import JSONB
39
+ from sqlalchemy.ext.asyncio import AsyncSession
40
+ from sqlalchemy.orm import Session
41
+
42
+ from lorebase_store.models import metadata
43
+ from lorebase_store.models.enums import Materialization
44
+
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
+
49
+ RECORD_COLUMNS = tuple(c.name for c in MAT_RECORDS.columns)
50
+ LINK_COLUMNS = tuple(c.name for c in MAT_LINKS.columns)
51
+ HISTORY_COLUMNS = tuple(c.name for c in MAT_HISTORY.columns)
52
+
53
+
54
+ def lineage_column(rows: FromClause, column: str = "lineage") -> Label[Any]:
55
+ """A map column of Record or Link `rows` (`lineage`, `lineage_revisions`) as the map it
56
+ stands for: with shared lineage maps (revision 0036) the column may hold a reference."""
57
+ return func.lb_lineage(rows.c.domain_id, rows.c[column], type_=JSONB).label(column)
58
+
59
+
60
+ _LAYOUT = text(
61
+ "SELECT CAST(materialization AS text), base_branch, base_generation FROM refs "
62
+ "WHERE domain_id = :domain AND name = :branch"
63
+ )
64
+
65
+
66
+ @dataclass(frozen=True, slots=True)
67
+ class BranchLayout:
68
+ """The rows a branch reads: its own (`base` is None) or an overlay on `base`."""
69
+
70
+ domain_id: str
71
+ branch: str
72
+ base: str | None = None
73
+ base_generation: int | None = None
74
+ building: bool = field(default=False, compare=False)
75
+ """A `building` branch: its own rows are still being written from its tree by a
76
+ background job (`materialize.background`); commits wait for it."""
77
+
78
+ def __post_init__(self) -> None:
79
+ if (self.base is None) != (self.base_generation is None):
80
+ raise ValueError("an overlay needs both its base branch and its base generation")
81
+
82
+ @property
83
+ def overlay(self) -> bool:
84
+ return self.base is not None
85
+
86
+ # SQL text -----------------------------------------------------------------------
87
+
88
+ def records_sql(self, *, probe: bool = True) -> str:
89
+ """The resolved Record rows as one statement (all `mat_records` columns).
90
+
91
+ `probe` keeps the anti-join a primary-key probe of the overlay per base row read,
92
+ right for lookups by key and short pages; false lets the planner hash the overlay's
93
+ keys instead, which reads that match many base rows (search) prefer.
94
+ """
95
+ return self._resolved("mat_records", RECORD_COLUMNS, ("kind_id", "key"), probe=probe)
96
+
97
+ def links_sql(self) -> str:
98
+ """The resolved Link rows as one statement (all `mat_links` columns)."""
99
+ return self._resolved("mat_links", LINK_COLUMNS, ("relation_id", "from_key", "to_key"))
100
+
101
+ def history_sql(self) -> str:
102
+ """The branch history: the overlay's own entries and the base's up to the fork that
103
+ it does not hold itself (a background copy, `materialize.background`, copies them in
104
+ batches before the overlay becomes a full copy)."""
105
+ own = ", ".join(f"h.{_quoted(c)}" for c in HISTORY_COLUMNS)
106
+ base = ", ".join(
107
+ f"CAST({_literal(self.branch)} AS text)" if c == "branch" else f"b.{_quoted(c)}"
108
+ for c in HISTORY_COLUMNS
109
+ )
110
+ domain = _literal(self.domain_id)
111
+ return (
112
+ f"SELECT {own} FROM mat_history h WHERE h.domain_id = {domain} "
113
+ f"AND h.branch = {_literal(self.branch)} UNION ALL SELECT {base} FROM mat_history b "
114
+ f"WHERE b.domain_id = {domain} AND b.branch = {_literal(self._base())} "
115
+ f"AND EXISTS (SELECT 1 FROM revisions r WHERE r.domain_id = {domain} "
116
+ f"AND r.id = b.revision_id AND r.generation <= {self._generation()} OFFSET 0) "
117
+ f"AND {self._guard()} AND NOT EXISTS (SELECT 1 FROM mat_history x "
118
+ f"WHERE x.domain_id = {domain} AND x.branch = {_literal(self.branch)} "
119
+ f"AND x.partition = b.partition AND x.element_key = b.element_key "
120
+ f"AND x.revision_id = b.revision_id OFFSET 0)"
121
+ )
122
+
123
+ def records_from(self, alias: str | None = None, *, probe: bool = True) -> str:
124
+ """A FROM item for text statements: `mat_records [alias]` or the resolved rows
125
+ (`probe` as in `records_sql`)."""
126
+ return self._from_item("mat_records", alias, lambda: self.records_sql(probe=probe))
127
+
128
+ def links_from(self, alias: str | None = None) -> str:
129
+ return self._from_item("mat_links", alias, self.links_sql)
130
+
131
+ def history_from(self, alias: str | None = None) -> str:
132
+ return self._from_item("mat_history", alias, self.history_sql)
133
+
134
+ def records_lookup(self, where: str) -> str:
135
+ """The resolved Record rows of an overlay that match `where`, as a parenthesized
136
+ subquery for a lateral lookup by identity (conditions on alias `x` that pin one
137
+ identity or a few, without bind parameters).
138
+
139
+ One index scan reads the overlay's and the base's rows together (`branch IN
140
+ (overlay, base)`), and the overlay's row of an identity, tombstone included, wins
141
+ over the base's (`DISTINCT ON`): one scan per lookup instead of one per half plus a
142
+ primary-key probe of the overlay per base row. Revision `0012` renders the same
143
+ text inside the read functions (`lb_overlay_lookup`).
144
+ """
145
+ return self._lookup("mat_records", RECORD_COLUMNS, ("kind_id", "key"), where)
146
+
147
+ def links_lookup(self, where: str) -> str:
148
+ """The resolved Link rows of an overlay that match `where` (see `records_lookup`)."""
149
+ key = ("relation_id", "from_key", "to_key")
150
+ return self._lookup("mat_links", LINK_COLUMNS, key, where)
151
+
152
+ # SQLAlchemy ---------------------------------------------------------------------
153
+
154
+ def records(self, name: str | None = None) -> FromClause:
155
+ """`mat_records` (aliased when `name` is given) or the resolved rows of an overlay."""
156
+ return self._from_clause(MAT_RECORDS, name)
157
+
158
+ def links(self, name: str | None = None) -> FromClause:
159
+ return self._from_clause(MAT_LINKS, name)
160
+
161
+ def history(self, name: str | None = None) -> FromClause:
162
+ return self._from_clause(MAT_HISTORY, name)
163
+
164
+ def records_alias(self) -> FromClause:
165
+ """An anonymous alias of the rows (for self-joins)."""
166
+ return self._resolved_clause(MAT_RECORDS) if self.overlay else MAT_RECORDS.alias()
167
+
168
+ def links_alias(self) -> FromClause:
169
+ return self._resolved_clause(MAT_LINKS) if self.overlay else MAT_LINKS.alias()
170
+
171
+ # Rendering ----------------------------------------------------------------------
172
+
173
+ def _resolved(
174
+ self, table: str, columns: tuple[str, ...], key: tuple[str, ...], *, probe: bool = True
175
+ ) -> str:
176
+ own, base = self._halves(table, columns, key, own="o", base="s", probe=probe)
177
+ return f"{own} UNION ALL {base}"
178
+
179
+ def _halves(
180
+ self,
181
+ table: str,
182
+ columns: tuple[str, ...],
183
+ key: tuple[str, ...],
184
+ *,
185
+ own: str,
186
+ base: str,
187
+ probe: bool = True,
188
+ ) -> tuple[str, str]:
189
+ """The overlay half and the base half of the resolved rows."""
190
+ domain, branch = _literal(self.domain_id), _literal(self.branch)
191
+ own_columns = ", ".join(f"{own}.{_quoted(c)}" for c in columns)
192
+ base_columns = ", ".join(
193
+ f"CAST({branch} AS text)" if c == "branch" else f"{base}.{_quoted(c)}" for c in columns
194
+ )
195
+ shadow = " AND ".join(f"x.{c} = {base}.{c}" for c in key)
196
+ # Postgres does not flatten a UNION ALL whose members have WHERE clauses: the halves
197
+ # stay subqueries; restriction quals and lateral references are pushed into each,
198
+ # ordering and LIMIT are not (`records_arms` repeats them per half). OFFSET 0 keeps
199
+ # the anti-join a primary-key probe per base row read (`probe`).
200
+ return (
201
+ f"SELECT {own_columns} FROM {table} {own} WHERE {own}.domain_id = {domain} "
202
+ f"AND {own}.branch = {branch} AND NOT {own}.deleted",
203
+ f"SELECT {base_columns} FROM {table} {base} WHERE {base}.domain_id = {domain} "
204
+ f"AND {base}.branch = {_literal(self._base())} "
205
+ f"AND {base}.revision_generation <= {self._generation()} AND {self._guard()} "
206
+ f"AND NOT EXISTS (SELECT 1 FROM {table} x WHERE x.domain_id = {domain} "
207
+ f"AND x.branch = {branch} AND {shadow}{' OFFSET 0' if probe else ''})",
208
+ )
209
+
210
+ def records_arms(self, tail: str, alias: str = "m") -> str:
211
+ """A FROM item with the resolved Record rows of an overlay where `tail` is applied
212
+ inside each half: conditions (starting with ` AND`) on alias `m`, then `ORDER BY` and
213
+ `LIMIT`, so that a keyset page reads at most `LIMIT` rows of each half. `tail` may hold
214
+ bind parameters and must not test `branch` (the halves read different branches)."""
215
+ if not self.overlay:
216
+ raise ValueError("records_arms applies to overlay branches")
217
+ own, base = (
218
+ escape_colons(half)
219
+ for half in self._halves(
220
+ "mat_records", RECORD_COLUMNS, ("kind_id", "key"), own="m", base="m"
221
+ )
222
+ )
223
+ return f"(({own}{tail}) UNION ALL ({base}{tail})) AS {alias}"
224
+
225
+ def _lookup(
226
+ self, table: str, columns: tuple[str, ...], key: tuple[str, ...], where: str
227
+ ) -> str:
228
+ if not self.overlay:
229
+ raise ValueError("lookups over both branches apply to overlay branches")
230
+ domain, branch, base = (
231
+ _literal(self.domain_id),
232
+ _literal(self.branch),
233
+ _literal(self._base()),
234
+ )
235
+ select = ", ".join(
236
+ f"CAST({branch} AS text) AS branch" if c == "branch" else f"x.{_quoted(c)}"
237
+ for c in columns
238
+ )
239
+ identity = ", ".join(f"x.{c}" for c in key)
240
+ return escape_colons(
241
+ f"(SELECT {select} FROM (SELECT DISTINCT ON ({identity}) * FROM {table} x "
242
+ f"WHERE x.domain_id = {domain} AND x.branch IN ({branch}, {base}) "
243
+ f"AND (x.branch = {branch} OR (x.revision_generation <= {self._generation()} "
244
+ f"AND {self._guard()})) AND {where} "
245
+ f"ORDER BY {identity}, x.branch = {branch} DESC) x WHERE NOT x.deleted)"
246
+ )
247
+
248
+ def _guard(self) -> str:
249
+ return (
250
+ f"EXISTS (SELECT 1 FROM refs f WHERE f.domain_id = {_literal(self.domain_id)} "
251
+ f"AND f.name = {_literal(self.branch)} AND f.base_branch = {_literal(self._base())} "
252
+ f"AND f.base_generation = {self._generation()} OFFSET 0)"
253
+ )
254
+
255
+ def _base(self) -> str:
256
+ assert self.base is not None
257
+ return self.base
258
+
259
+ def _generation(self) -> int:
260
+ assert self.base_generation is not None
261
+ return int(self.base_generation)
262
+
263
+ def _from_item(self, table: str, alias: str | None, resolved: Any) -> str:
264
+ if not self.overlay:
265
+ return table if alias is None else f"{table} {alias}"
266
+ return f"({escape_colons(resolved())}) AS {alias or table}"
267
+
268
+ def _from_clause(self, table: Table, name: str | None) -> FromClause:
269
+ if not self.overlay:
270
+ return table.alias(name) if name else table
271
+ return self._resolved_clause(table, name or table.name)
272
+
273
+ def _resolved_clause(self, table: Table, name: str | None = None) -> FromClause:
274
+ sql = {
275
+ MAT_RECORDS.name: self.records_sql,
276
+ MAT_LINKS.name: self.links_sql,
277
+ MAT_HISTORY.name: self.history_sql,
278
+ }[table.name]()
279
+ return text(escape_colons(sql)).columns(*table.columns).subquery(name)
280
+
281
+
282
+ def escape_colons(fragment: str) -> str:
283
+ """A literal-holding SQL fragment made safe for `text()`, where `:name` binds."""
284
+ return fragment.replace(":", r"\:")
285
+
286
+
287
+ def full_layout(domain_id: str, branch: str) -> BranchLayout:
288
+ return BranchLayout(domain_id, branch)
289
+
290
+
291
+ def layout_from(
292
+ domain_id: str,
293
+ branch: str,
294
+ materialization: Materialization | str | None,
295
+ base: str | None,
296
+ generation: int | None,
297
+ ) -> BranchLayout:
298
+ """The layout of a ref row's materialization columns."""
299
+ if materialization == Materialization.OVERLAY and base is not None:
300
+ return BranchLayout(domain_id, branch, base, generation)
301
+ return BranchLayout(domain_id, branch, building=materialization == Materialization.BUILDING)
302
+
303
+
304
+ async def load_layout(session: AsyncSession, domain_id: str, branch: str) -> BranchLayout:
305
+ """The layout of a branch (one primary-key read); a missing ref reads as full."""
306
+ found = (await session.execute(_LAYOUT, {"domain": domain_id, "branch": branch})).one_or_none()
307
+ if found is None:
308
+ return BranchLayout(domain_id, branch)
309
+ return layout_from(domain_id, branch, found[0], found[1], found[2])
310
+
311
+
312
+ def load_layout_sync(session: Session, domain_id: str, branch: str) -> BranchLayout:
313
+ found = session.execute(_LAYOUT, {"domain": domain_id, "branch": branch}).one_or_none()
314
+ if found is None:
315
+ return BranchLayout(domain_id, branch)
316
+ return layout_from(domain_id, branch, found[0], found[1], found[2])
317
+
318
+
319
+ async def resolve_layout(
320
+ session: AsyncSession, domain_id: str, branch: str | BranchLayout
321
+ ) -> BranchLayout:
322
+ """`branch` itself when it is a layout, else the layout read from its ref."""
323
+ if isinstance(branch, BranchLayout):
324
+ return branch
325
+ return await load_layout(session, domain_id, branch)
326
+
327
+
328
+ def _literal(value: str) -> str:
329
+ if "\x00" in value:
330
+ raise ValueError("SQL literals cannot contain NUL")
331
+ return "'" + value.replace("'", "''") + "'"
332
+
333
+
334
+ def _quoted(column: str) -> str:
335
+ return f'"{column}"'
@@ -0,0 +1,103 @@
1
+ """Materialized heads (design 6.11): incremental apply, full rebuild, branch copy or overlay,
2
+ promotion of overlays, background copies and deletions of large branches, rewrite of rows
3
+ materialized before a sensitivity tag, backfill of lineage revisions, verify."""
4
+
5
+ from lorebase_store.jobs import (
6
+ BRANCH_DELETE_JOB,
7
+ LINEAGE_BACKFILL_JOB,
8
+ MATERIALIZE_JOB,
9
+ SENSITIVITY_REFRESH_JOB,
10
+ )
11
+ from lorebase_store.materialize.apply import MaterializedChange, apply_changeset, refresh_kind
12
+ from lorebase_store.materialize.background import (
13
+ BranchDeletion,
14
+ delete_branch,
15
+ queue_materialization,
16
+ run_branch_delete_job,
17
+ run_materialize_job,
18
+ )
19
+ from lorebase_store.materialize.branch import (
20
+ BranchCreation,
21
+ BranchDeletingError,
22
+ BranchExistsError,
23
+ BranchMode,
24
+ MaterializationMode,
25
+ create_branch,
26
+ )
27
+ from lorebase_store.materialize.lineage_backfill import (
28
+ LineageBackfill,
29
+ backfill_lineage_revisions,
30
+ queue_lineage_sweep,
31
+ run_lineage_backfill_job,
32
+ )
33
+ from lorebase_store.materialize.overlay import (
34
+ OverlayError,
35
+ promote,
36
+ promote_overlays_of,
37
+ refork,
38
+ )
39
+ from lorebase_store.materialize.paths import (
40
+ refresh_path_fanout,
41
+ refresh_stale_paths,
42
+ run_path_fanout_sweep,
43
+ )
44
+ from lorebase_store.materialize.rebuild import RebuildReport, materialize_revision, rebuild
45
+ from lorebase_store.materialize.stale import (
46
+ StaleRefresh,
47
+ queue_after_tagging,
48
+ queue_stale_refresh,
49
+ refresh_stale_rows,
50
+ run_sensitivity_refresh_job,
51
+ stale_kinds,
52
+ )
53
+ from lorebase_store.materialize.verify import (
54
+ Divergence,
55
+ DivergenceReason,
56
+ VerifyReport,
57
+ verify,
58
+ )
59
+
60
+ __all__ = [
61
+ "BRANCH_DELETE_JOB",
62
+ "LINEAGE_BACKFILL_JOB",
63
+ "MATERIALIZE_JOB",
64
+ "SENSITIVITY_REFRESH_JOB",
65
+ "BranchCreation",
66
+ "BranchDeletingError",
67
+ "BranchDeletion",
68
+ "BranchExistsError",
69
+ "BranchMode",
70
+ "Divergence",
71
+ "DivergenceReason",
72
+ "LineageBackfill",
73
+ "MaterializationMode",
74
+ "MaterializedChange",
75
+ "OverlayError",
76
+ "RebuildReport",
77
+ "StaleRefresh",
78
+ "VerifyReport",
79
+ "apply_changeset",
80
+ "backfill_lineage_revisions",
81
+ "create_branch",
82
+ "delete_branch",
83
+ "materialize_revision",
84
+ "promote",
85
+ "promote_overlays_of",
86
+ "queue_after_tagging",
87
+ "queue_lineage_sweep",
88
+ "queue_materialization",
89
+ "queue_stale_refresh",
90
+ "rebuild",
91
+ "refork",
92
+ "refresh_kind",
93
+ "refresh_path_fanout",
94
+ "refresh_stale_paths",
95
+ "refresh_stale_rows",
96
+ "run_branch_delete_job",
97
+ "run_lineage_backfill_job",
98
+ "run_materialize_job",
99
+ "run_path_fanout_sweep",
100
+ "run_sensitivity_refresh_job",
101
+ "stale_kinds",
102
+ "verify",
103
+ ]