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,205 @@
1
+ """Outbox events written in commit transactions (design 6.6 step 8, 10.5) and read by the
2
+ change feed and the consumers of the outbox.
3
+
4
+ The audit entry of the same step goes through `lorebase_store.auth.AuditRepository`, which owns
5
+ the per-Domain hash chain. Outbox ids increase with every event, so they serve as the sequence
6
+ numbers of the change feed (`Last-Event-ID`).
7
+
8
+ Each consumer (`outbox_consumers`) owns its delivery state: the events it has not handed off
9
+ yet are its `outbox_pending` rows, added by a trigger in the transaction writing the event, so
10
+ an event committed late (its transaction began before others that committed first) is never
11
+ skipped the way a position in the id sequence could skip it. A consumer claims its pending
12
+ rows (`claim`, skipping rows another worker of the same consumer holds) and deletes them with
13
+ `acknowledge` in the transaction that hands the events off; a failure before that leaves them
14
+ pending (at-least-once). Several consumers never take events from one another.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Sequence
20
+ from dataclasses import dataclass
21
+ from datetime import datetime
22
+ from typing import Any
23
+
24
+ from sqlalchemy import Table, and_, delete, exists, func, insert, or_, select
25
+ from sqlalchemy.dialects.postgresql import insert as upsert
26
+ from sqlalchemy.ext.asyncio import AsyncSession
27
+
28
+ from lorebase_store.models import metadata
29
+
30
+ OUTBOX: Table = metadata.tables["outbox"]
31
+ OUTBOX_CONSUMERS: Table = metadata.tables["outbox_consumers"]
32
+ OUTBOX_PENDING: Table = metadata.tables["outbox_pending"]
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class OutboxRow:
37
+ id: int
38
+ domain_id: str
39
+ event_type: str
40
+ payload: dict[str, Any]
41
+ created_at: datetime
42
+
43
+
44
+ def _row(row: Any) -> OutboxRow:
45
+ return OutboxRow(row.id, row.domain_id, row.event_type, row.payload, row.created_at)
46
+
47
+
48
+ _COLUMNS = (
49
+ OUTBOX.c.id,
50
+ OUTBOX.c.domain_id,
51
+ OUTBOX.c.event_type,
52
+ OUTBOX.c.payload,
53
+ OUTBOX.c.created_at,
54
+ )
55
+
56
+
57
+ class OutboxRepository:
58
+ """Events delivered after commit by a background worker."""
59
+
60
+ def __init__(self, session: AsyncSession) -> None:
61
+ self._session = session
62
+
63
+ async def enqueue(self, domain_id: str, event_type: str, payload: dict[str, Any]) -> int:
64
+ result = await self._session.execute(
65
+ insert(OUTBOX)
66
+ .values(domain_id=domain_id, event_type=event_type, payload=payload)
67
+ .returning(OUTBOX.c.id)
68
+ )
69
+ return int(result.scalar_one())
70
+
71
+ async def latest(self, domain_id: str) -> int:
72
+ """Id of the newest event of the Domain (0 when there is none)."""
73
+ found = await self._session.scalar(
74
+ select(func.max(OUTBOX.c.id)).where(OUTBOX.c.domain_id == domain_id)
75
+ )
76
+ return int(found or 0)
77
+
78
+ async def after(
79
+ self,
80
+ domain_id: str,
81
+ after: int,
82
+ *,
83
+ event_types: Sequence[str],
84
+ branch: str | None,
85
+ limit: int,
86
+ ) -> list[OutboxRow]:
87
+ """Events of the Domain newer than `after`, oldest first; `branch` filters on the
88
+ `branch` member of the payload."""
89
+ query = (
90
+ select(*_COLUMNS)
91
+ .where(
92
+ OUTBOX.c.domain_id == domain_id,
93
+ OUTBOX.c.id > after,
94
+ OUTBOX.c.event_type.in_(list(event_types)),
95
+ )
96
+ .order_by(OUTBOX.c.id)
97
+ .limit(limit)
98
+ )
99
+ if branch is not None:
100
+ query = query.where(OUTBOX.c.payload["branch"].astext == branch)
101
+ return [_row(row) for row in await self._session.execute(query)]
102
+
103
+ async def claim(self, consumer: str, limit: int) -> list[OutboxRow]:
104
+ """The oldest events pending for `consumer`, in every Domain visible to the
105
+ transaction; their pending rows stay locked until it ends (other workers of the
106
+ consumer skip them)."""
107
+ p = OUTBOX_PENDING
108
+ claimed = (
109
+ select(p.c.event_id)
110
+ .where(p.c.consumer == consumer)
111
+ .order_by(p.c.event_id)
112
+ .limit(limit)
113
+ .with_for_update(skip_locked=True)
114
+ .cte("claimed")
115
+ )
116
+ query = (
117
+ select(*_COLUMNS).join(claimed, claimed.c.event_id == OUTBOX.c.id).order_by(OUTBOX.c.id)
118
+ )
119
+ return [_row(row) for row in await self._session.execute(query)]
120
+
121
+ async def acknowledge(self, consumer: str, ids: Sequence[int]) -> None:
122
+ """`consumer` handed these events off: they are no longer pending for it."""
123
+ if ids:
124
+ await self._session.execute(
125
+ delete(OUTBOX_PENDING).where(
126
+ OUTBOX_PENDING.c.consumer == consumer,
127
+ OUTBOX_PENDING.c.event_id.in_(list(ids)),
128
+ )
129
+ )
130
+
131
+ async def pending(self) -> dict[str, int]:
132
+ """Events each registered consumer has not handed off yet (its backlog)."""
133
+ p, c = OUTBOX_PENDING, OUTBOX_CONSUMERS
134
+ result = await self._session.execute(
135
+ select(c.c.name, func.count(p.c.event_id))
136
+ .select_from(c.outerjoin(p, p.c.consumer == c.c.name))
137
+ .group_by(c.c.name)
138
+ )
139
+ return {name: int(count) for name, count in result}
140
+
141
+ async def prune(
142
+ self, *, before: datetime, expire_before: datetime, limit: int
143
+ ) -> tuple[int, int]:
144
+ """Delete up to `limit` events created before `expire_before` whatever is pending, then
145
+ up to `limit` created before `before` that no consumer has pending, oldest first; the
146
+ numbers deleted (handed off, expired). Bounded, so a large backlog is deleted over
147
+ several calls instead of in one long transaction."""
148
+ expired_ids = (
149
+ select(OUTBOX.c.id)
150
+ .where(OUTBOX.c.created_at < expire_before)
151
+ .order_by(OUTBOX.c.id)
152
+ .limit(limit)
153
+ )
154
+ expired = await self._session.execute(
155
+ delete(OUTBOX).where(OUTBOX.c.id.in_(expired_ids)).returning(OUTBOX.c.id)
156
+ )
157
+ handed_ids = (
158
+ select(OUTBOX.c.id)
159
+ .where(
160
+ OUTBOX.c.created_at < before,
161
+ ~exists().where(OUTBOX_PENDING.c.event_id == OUTBOX.c.id),
162
+ )
163
+ .order_by(OUTBOX.c.id)
164
+ .limit(limit)
165
+ )
166
+ handed = await self._session.execute(
167
+ delete(OUTBOX).where(OUTBOX.c.id.in_(handed_ids)).returning(OUTBOX.c.id)
168
+ )
169
+ return len(handed.all()), len(expired.all())
170
+
171
+ async def prunable(self, *, before: datetime, expire_before: datetime, cap: int) -> int:
172
+ """Events `prune` would delete now, counted up to `cap`. Pending events between the
173
+ two cutoffs are scanned to find them, so callers count only after a pruning pass that
174
+ ran out of time with work left."""
175
+ eligible = (
176
+ select(OUTBOX.c.id)
177
+ .where(
178
+ or_(
179
+ OUTBOX.c.created_at < expire_before,
180
+ and_(
181
+ OUTBOX.c.created_at < before,
182
+ ~exists().where(OUTBOX_PENDING.c.event_id == OUTBOX.c.id),
183
+ ),
184
+ )
185
+ )
186
+ .limit(cap)
187
+ .subquery()
188
+ )
189
+ result = await self._session.execute(select(func.count()).select_from(eligible))
190
+ return int(result.scalar_one())
191
+
192
+
193
+ async def register_consumer(session: AsyncSession, name: str) -> None:
194
+ """Make `name` a consumer of the outbox: events written from now on are pending for it."""
195
+ await session.execute(upsert(OUTBOX_CONSUMERS).values(name=name).on_conflict_do_nothing())
196
+
197
+
198
+ async def drop_consumer(session: AsyncSession, name: str) -> bool:
199
+ """Stop keeping events for `name` (its pending rows go too); false when unknown."""
200
+ result = await session.execute(
201
+ delete(OUTBOX_CONSUMERS)
202
+ .where(OUTBOX_CONSUMERS.c.name == name)
203
+ .returning(OUTBOX_CONSUMERS.c.name)
204
+ )
205
+ return result.first() is not None
@@ -0,0 +1,489 @@
1
+ """Core `IndexView` backed by the materialized head of a branch.
2
+
3
+ Validation (core `evaluate_changeset`) asks one question at a time. To keep a batch of
4
+ thousands of changes at a handful of queries, `prefetch_for` loads in bulk what the batch will
5
+ ask (existence and state of touched Records and Links, unique values, Link counts and Links of
6
+ deleted Records); the view then answers from memory and falls back to one query per miss.
7
+
8
+ Like `PgNodeStore`, the view runs over a synchronous `Session` inside `AsyncSession.run_sync`.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from collections import defaultdict
15
+ from collections.abc import Iterable, Iterator, Sequence
16
+ from dataclasses import dataclass
17
+ from typing import TYPE_CHECKING, Any, Literal
18
+
19
+ from pydantic import JsonValue
20
+ from sqlalchemy import ColumnElement, FromClause, TextClause, and_, func, select, text, tuple_
21
+ from sqlalchemy.orm import Session
22
+
23
+ from lorebase_core.model import Link, Record, RecordIdentity, RelationBacking, Schema
24
+ from lorebase_core.model.record import LinkIdentity
25
+ from lorebase_core.types import AttributeType, CanonicalValue
26
+ from lorebase_core.validate import DataChange, LinkUpsert, RecordDelete, RecordUpsert
27
+ from lorebase_store.bulk import chunks, pairs_in
28
+ from lorebase_store.layout import BranchLayout, lineage_column, load_layout_sync
29
+
30
+ if TYPE_CHECKING:
31
+ from lorebase_store.indexes import IndexSpec
32
+
33
+ Side = Literal["from", "to"]
34
+
35
+ _UNIQUE_VALUES = """
36
+ SELECT key, "values" -> :attribute AS value
37
+ FROM {records}
38
+ WHERE domain_id = :domain AND branch = :branch AND kind_id = :kind
39
+ AND "values" -> :attribute IN (SELECT jsonb_array_elements(CAST(:candidates AS jsonb)))
40
+ """
41
+ #: `_UNIQUE_VALUES` through the Attribute's unique index: its predicate as literals and its
42
+ #: expression compared with the candidates' canonical text (`IndexSpec.value_sql`).
43
+ _UNIQUE_INDEXED = """
44
+ SELECT key, "values" -> :attribute AS value
45
+ FROM {records}
46
+ WHERE {predicate}
47
+ AND {expression} IN (
48
+ SELECT {value} FROM jsonb_array_elements_text(CAST(:candidates AS jsonb)) AS c(v))
49
+ """
50
+ _REFERENCING = """
51
+ SELECT key, "values" -> :attribute AS value
52
+ FROM {records}
53
+ WHERE domain_id = :domain AND branch = :branch AND kind_id = :kind
54
+ AND jsonb_exists_any("values" -> :attribute, CAST(:keys AS text[]))
55
+ """
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class RecordState:
60
+ """A Record as materialized before the batch."""
61
+
62
+ values: dict[str, Any]
63
+ lineage: dict[str, str]
64
+ object_hash: bytes
65
+
66
+
67
+ @dataclass(frozen=True, slots=True)
68
+ class LinkState:
69
+ """A Link as materialized before the batch."""
70
+
71
+ values: dict[str, Any]
72
+ lineage: dict[str, str]
73
+ object_hash: bytes
74
+
75
+
76
+ @dataclass
77
+ class IndexStats:
78
+ queries: int = 0
79
+
80
+
81
+ def value_key(value: JsonValue) -> str:
82
+ """Memo key of a canonical value."""
83
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
84
+
85
+
86
+ def _keys_in(value: JsonValue) -> list[str]:
87
+ if isinstance(value, str):
88
+ return [value]
89
+ if isinstance(value, list):
90
+ return [item for item in value if isinstance(item, str)]
91
+ return []
92
+
93
+
94
+ class MaterializedIndexView:
95
+ """`IndexView` over `mat_records` and `mat_links` of one branch, with bulk prefetch.
96
+
97
+ An overlay branch is read through its resolved rows (`lorebase_store.layout`).
98
+ """
99
+
100
+ def __init__(
101
+ self,
102
+ session: Session,
103
+ domain_id: str,
104
+ branch: str,
105
+ *,
106
+ batch: int,
107
+ layout: BranchLayout | None = None,
108
+ ) -> None:
109
+ self._session = session
110
+ self._domain = domain_id
111
+ self._branch = branch
112
+ self._batch = batch
113
+ self._layout = layout
114
+ self._records: dict[tuple[str, str], RecordState | None] = {}
115
+ self._links: dict[tuple[str, str, str], LinkState | None] = {}
116
+ self._unique: dict[tuple[str, str, str], str | None] = {}
117
+ self._counts: dict[tuple[str, Side, str], int] = {}
118
+ self._pairs: dict[tuple[str, Side, str], list[tuple[str, str]]] = {}
119
+ self._referencing: dict[tuple[str, str, str], set[str]] = {}
120
+ self._unique_indexes: dict[tuple[str, str], IndexSpec] = {}
121
+ self.stats = IndexStats()
122
+
123
+ # IndexView protocol ---------------------------------------------------------------
124
+
125
+ def record_exists(self, kind_id: str, key: str) -> bool:
126
+ return self.record_state(kind_id, key) is not None
127
+
128
+ def find_unique(self, kind_id: str, attribute_id: str, value: CanonicalValue) -> str | None:
129
+ memo = (kind_id, attribute_id, value_key(value))
130
+ if memo not in self._unique:
131
+ self.prefetch_unique(kind_id, attribute_id, [value])
132
+ return self._unique.get(memo)
133
+
134
+ def keys_referencing(self, kind_id: str, attribute_id: str, key: str) -> Iterable[str]:
135
+ memo = (kind_id, attribute_id, key)
136
+ if memo not in self._referencing:
137
+ self.prefetch_referencing(kind_id, attribute_id, [key])
138
+ return sorted(self._referencing[memo])
139
+
140
+ def link_exists(self, relation_id: str, from_key: str, to_key: str) -> bool:
141
+ return self.link_state(relation_id, from_key, to_key) is not None
142
+
143
+ def count_links(
144
+ self, relation_id: str, *, from_key: str | None = None, to_key: str | None = None
145
+ ) -> int:
146
+ side, key = _side(from_key, to_key)
147
+ if side is None:
148
+ return self._count_all(relation_id, from_key, to_key)
149
+ memo = (relation_id, side, key)
150
+ if memo not in self._counts:
151
+ self.prefetch_link_counts(relation_id, side, [key])
152
+ return self._counts[memo]
153
+
154
+ def links_of(
155
+ self, relation_id: str, *, from_key: str | None = None, to_key: str | None = None
156
+ ) -> Iterable[tuple[str, str]]:
157
+ side, key = _side(from_key, to_key)
158
+ if side is None:
159
+ return [
160
+ pair
161
+ for pair in self._pairs_query(relation_id, None, [])
162
+ if (from_key is None or pair[0] == from_key)
163
+ and (to_key is None or pair[1] == to_key)
164
+ ]
165
+ memo = (relation_id, side, key)
166
+ if memo not in self._pairs:
167
+ self.prefetch_links_of(relation_id, side, [key])
168
+ return list(self._pairs[memo])
169
+
170
+ # State before the batch -------------------------------------------------------------
171
+
172
+ def record_state(self, kind_id: str, key: str) -> RecordState | None:
173
+ memo = (kind_id, key)
174
+ if memo not in self._records:
175
+ self.prefetch_records([RecordIdentity(kind_id, key)])
176
+ return self._records[memo]
177
+
178
+ def link_state(self, relation_id: str, from_key: str, to_key: str) -> LinkState | None:
179
+ memo = (relation_id, from_key, to_key)
180
+ if memo not in self._links:
181
+ self.prefetch_links([LinkIdentity(relation_id, from_key, to_key)])
182
+ return self._links[memo]
183
+
184
+ def records_of_kind(self, kind_id: str) -> Iterator[Record]:
185
+ """Every materialized Record of a Kind, in key order."""
186
+ rows_from = self._record_rows()
187
+ last: str | None = None
188
+ while True:
189
+ query = (
190
+ select(rows_from.c.key, rows_from.c["values"])
191
+ .where(self._scope(rows_from), rows_from.c.kind_id == kind_id)
192
+ .order_by(rows_from.c.key)
193
+ .limit(self._batch)
194
+ )
195
+ if last is not None:
196
+ query = query.where(rows_from.c.key > last)
197
+ rows = list(self._execute(query))
198
+ if not rows:
199
+ return
200
+ for key, values in rows:
201
+ yield Record(kind=kind_id, key=key, values=values)
202
+ last = rows[-1][0]
203
+
204
+ def links_of_relation(self, relation_id: str) -> Iterator[Link]:
205
+ """Every materialized Link of a Relation, in `(from, to)` order."""
206
+ links = self._link_rows()
207
+ last: tuple[str, str] | None = None
208
+ while True:
209
+ query = (
210
+ select(links.c.from_key, links.c.to_key, links.c["values"])
211
+ .where(self._scope(links), links.c.relation_id == relation_id)
212
+ .order_by(links.c.from_key, links.c.to_key)
213
+ .limit(self._batch)
214
+ )
215
+ if last is not None:
216
+ query = query.where(tuple_(links.c.from_key, links.c.to_key) > last)
217
+ rows = list(self._execute(query))
218
+ if not rows:
219
+ return
220
+ for from_key, to_key, values in rows:
221
+ yield Link(relation=relation_id, from_key=from_key, to_key=to_key, values=values)
222
+ last = (rows[-1][0], rows[-1][1])
223
+
224
+ # Bulk prefetch ----------------------------------------------------------------------
225
+
226
+ def prefetch_records(self, identities: Iterable[RecordIdentity]) -> None:
227
+ by_kind: dict[str, list[str]] = defaultdict(list)
228
+ for kind_id, key in dict.fromkeys(identities):
229
+ if (kind_id, key) not in self._records:
230
+ by_kind[kind_id].append(key)
231
+ rows = self._record_rows()
232
+ for kind_id, keys in by_kind.items():
233
+ for chunk in chunks(keys, self._batch):
234
+ for key in chunk:
235
+ self._records[(kind_id, key)] = None
236
+ query = select(
237
+ rows.c.key,
238
+ rows.c["values"],
239
+ lineage_column(rows),
240
+ rows.c.object_hash,
241
+ ).where(
242
+ self._scope(rows),
243
+ rows.c.kind_id == kind_id,
244
+ rows.c.key.in_(chunk),
245
+ )
246
+ for key, values, lineage, digest in self._execute(query):
247
+ self._records[(kind_id, key)] = RecordState(values, lineage, digest)
248
+
249
+ def prefetch_links(self, identities: Iterable[LinkIdentity]) -> None:
250
+ by_relation: dict[str, list[tuple[str, str]]] = defaultdict(list)
251
+ for relation_id, from_key, to_key in dict.fromkeys(identities):
252
+ if (relation_id, from_key, to_key) not in self._links:
253
+ by_relation[relation_id].append((from_key, to_key))
254
+ links = self._link_rows()
255
+ for relation_id, pairs in by_relation.items():
256
+ for chunk in chunks(pairs, self._batch):
257
+ for from_key, to_key in chunk:
258
+ self._links[(relation_id, from_key, to_key)] = None
259
+ query = select(
260
+ links.c.from_key,
261
+ links.c.to_key,
262
+ links.c["values"],
263
+ lineage_column(links),
264
+ links.c.object_hash,
265
+ ).where(
266
+ self._scope(links),
267
+ links.c.relation_id == relation_id,
268
+ pairs_in(links.c.from_key, links.c.to_key, chunk),
269
+ )
270
+ for from_key, to_key, values, lineage, digest in self._execute(query):
271
+ self._links[(relation_id, from_key, to_key)] = LinkState(
272
+ values, lineage, digest
273
+ )
274
+
275
+ def prefetch_unique(
276
+ self, kind_id: str, attribute_id: str, values: Iterable[CanonicalValue]
277
+ ) -> None:
278
+ pending = {
279
+ value_key(v): v
280
+ for v in values
281
+ if (kind_id, attribute_id, value_key(v)) not in self._unique
282
+ }
283
+ spec = self._unique_indexes.get((kind_id, attribute_id))
284
+ if spec is not None:
285
+ # Without the index's predicate and expression, the lookup reads every Record
286
+ # of the Kind.
287
+ statement = text(
288
+ _UNIQUE_INDEXED.format(
289
+ records=self.layout().records_from(),
290
+ predicate=spec.predicate_sql(self._domain, self._branch),
291
+ expression=spec.expression,
292
+ value=spec.value_sql("c.v"),
293
+ )
294
+ )
295
+ else:
296
+ statement = self._text(_UNIQUE_VALUES)
297
+ for chunk in chunks(list(pending), self._batch):
298
+ for memo in chunk:
299
+ self._unique[(kind_id, attribute_id, memo)] = None
300
+ rows = self._execute(
301
+ statement,
302
+ {
303
+ "domain": self._domain,
304
+ "branch": self._branch,
305
+ "kind": kind_id,
306
+ "attribute": attribute_id,
307
+ "candidates": json.dumps([pending[memo] for memo in chunk]),
308
+ },
309
+ )
310
+ for key, value in rows:
311
+ self._unique[(kind_id, attribute_id, value_key(value))] = key
312
+
313
+ def prefetch_referencing(self, kind_id: str, attribute_id: str, keys: Iterable[str]) -> None:
314
+ pending = [
315
+ k for k in dict.fromkeys(keys) if (kind_id, attribute_id, k) not in self._referencing
316
+ ]
317
+ for chunk in chunks(pending, self._batch):
318
+ for key in chunk:
319
+ self._referencing[(kind_id, attribute_id, key)] = set()
320
+ rows = self._execute(
321
+ self._text(_REFERENCING),
322
+ {
323
+ "domain": self._domain,
324
+ "branch": self._branch,
325
+ "kind": kind_id,
326
+ "attribute": attribute_id,
327
+ "keys": list(chunk),
328
+ },
329
+ )
330
+ wanted = set(chunk)
331
+ for record_key, value in rows:
332
+ for target in _keys_in(value):
333
+ if target in wanted:
334
+ self._referencing[(kind_id, attribute_id, target)].add(record_key)
335
+
336
+ def prefetch_link_counts(self, relation_id: str, side: Side, keys: Iterable[str]) -> None:
337
+ links = self._link_rows()
338
+ column = links.c.from_key if side == "from" else links.c.to_key
339
+ pending = [k for k in dict.fromkeys(keys) if (relation_id, side, k) not in self._counts]
340
+ for chunk in chunks(pending, self._batch):
341
+ for key in chunk:
342
+ self._counts[(relation_id, side, key)] = 0
343
+ query = (
344
+ select(column, func.count())
345
+ .where(
346
+ self._scope(links),
347
+ links.c.relation_id == relation_id,
348
+ column.in_(chunk),
349
+ )
350
+ .group_by(column)
351
+ )
352
+ for key, count in self._execute(query):
353
+ self._counts[(relation_id, side, key)] = int(count)
354
+
355
+ def prefetch_links_of(self, relation_id: str, side: Side, keys: Iterable[str]) -> None:
356
+ pending = [k for k in dict.fromkeys(keys) if (relation_id, side, k) not in self._pairs]
357
+ for chunk in chunks(pending, self._batch):
358
+ for key in chunk:
359
+ self._pairs[(relation_id, side, key)] = []
360
+ for pair in self._pairs_query(relation_id, side, chunk):
361
+ key = pair[0] if side == "from" else pair[1]
362
+ self._pairs[(relation_id, side, key)].append(pair)
363
+ for key in chunk:
364
+ self._counts[(relation_id, side, key)] = len(self._pairs[(relation_id, side, key)])
365
+
366
+ def prefetch_for(self, schema: Schema, changes: Sequence[DataChange]) -> None:
367
+ """Load in bulk what validating `changes` against `schema` will read."""
368
+ # Imported here: `lorebase_store.indexes` imports the repositories package.
369
+ from lorebase_store.indexes import plan_indexes
370
+
371
+ self._unique_indexes = {
372
+ key: spec for key, spec in plan_indexes(schema).by_attribute().items() if spec.unique
373
+ }
374
+ records: list[RecordIdentity] = []
375
+ links: list[LinkIdentity] = []
376
+ unique: dict[tuple[str, str], list[CanonicalValue]] = defaultdict(list)
377
+ counts: dict[tuple[str, Side], list[str]] = defaultdict(list)
378
+ pairs: dict[tuple[str, Side], list[str]] = defaultdict(list)
379
+ referencing: dict[tuple[str, str], list[str]] = defaultdict(list)
380
+ new_records: dict[str, list[str]] = defaultdict(list)
381
+ for change in changes:
382
+ if isinstance(change, RecordUpsert):
383
+ record = change.record
384
+ records.append(record.identity)
385
+ new_records[record.kind].append(record.key)
386
+ kind = schema.kinds.get(record.kind)
387
+ if kind is None:
388
+ continue
389
+ for attribute in kind.attributes.values():
390
+ value = record.values.get(attribute.id)
391
+ if value is None:
392
+ continue
393
+ if attribute.unique:
394
+ unique[(kind.id, attribute.id)].append(value)
395
+ target = attribute.type.scalar.kind_ref
396
+ if attribute.type.scalar.name is AttributeType.REF and target is not None:
397
+ records.extend(RecordIdentity(target, k) for k in _keys_in(value))
398
+ elif isinstance(change, RecordDelete):
399
+ records.append(change.identity)
400
+ for relation in schema.relations_of(change.kind):
401
+ if relation.backing is not RelationBacking.EXPLICIT:
402
+ continue
403
+ if relation.from_kind == change.kind:
404
+ pairs[(relation.id, "from")].append(change.key)
405
+ if relation.to_kind == change.kind:
406
+ pairs[(relation.id, "to")].append(change.key)
407
+ for kind in schema.kinds.values():
408
+ for attribute in kind.attributes.values():
409
+ scalar = attribute.type.scalar
410
+ if scalar.name is AttributeType.REF and scalar.kind_ref == change.kind:
411
+ referencing[(kind.id, attribute.id)].append(change.key)
412
+ else:
413
+ identity = change.identity
414
+ links.append(identity)
415
+ linked = schema.relations.get(identity.relation)
416
+ if linked is None:
417
+ continue
418
+ records.append(RecordIdentity(linked.from_kind, identity.from_key))
419
+ records.append(RecordIdentity(linked.to_kind, identity.to_key))
420
+ if linked.single_per_from or linked.required_from:
421
+ counts[(linked.id, "from")].append(identity.from_key)
422
+ if linked.single_per_to and isinstance(change, LinkUpsert):
423
+ counts[(linked.id, "to")].append(identity.to_key)
424
+ for required in schema.relations.values():
425
+ if required.required_from:
426
+ counts[(required.id, "from")].extend(new_records.get(required.from_kind, ()))
427
+ self.prefetch_records(records)
428
+ self.prefetch_links(links)
429
+ for (kind_id, attribute_id), values in unique.items():
430
+ self.prefetch_unique(kind_id, attribute_id, values)
431
+ for (relation_id, side), keys in pairs.items():
432
+ self.prefetch_links_of(relation_id, side, keys)
433
+ for (relation_id, side), keys in counts.items():
434
+ self.prefetch_link_counts(relation_id, side, keys)
435
+ for (kind_id, attribute_id), keys in referencing.items():
436
+ self.prefetch_referencing(kind_id, attribute_id, keys)
437
+
438
+ # Queries ----------------------------------------------------------------------------
439
+
440
+ def layout(self) -> BranchLayout:
441
+ if self._layout is None:
442
+ self._layout = load_layout_sync(self._session, self._domain, self._branch)
443
+ return self._layout
444
+
445
+ def _record_rows(self) -> FromClause:
446
+ return self.layout().records()
447
+
448
+ def _link_rows(self) -> FromClause:
449
+ return self.layout().links()
450
+
451
+ def _text(self, template: str) -> TextClause:
452
+ return text(template.format(records=self.layout().records_from()))
453
+
454
+ def _scope(self, table: Any) -> ColumnElement[bool]:
455
+ return and_(table.c.domain_id == self._domain, table.c.branch == self._branch)
456
+
457
+ def _execute(self, statement: Any, parameters: dict[str, Any] | None = None) -> Any:
458
+ self.stats.queries += 1
459
+ return self._session.execute(statement, parameters or {})
460
+
461
+ def _pairs_query(
462
+ self, relation_id: str, side: Side | None, keys: Sequence[str]
463
+ ) -> list[tuple[str, str]]:
464
+ links = self._link_rows()
465
+ query = select(links.c.from_key, links.c.to_key).where(
466
+ self._scope(links), links.c.relation_id == relation_id
467
+ )
468
+ if side == "from":
469
+ query = query.where(links.c.from_key.in_(keys))
470
+ elif side == "to":
471
+ query = query.where(links.c.to_key.in_(keys))
472
+ return [(f, t) for f, t in self._execute(query)]
473
+
474
+ def _count_all(self, relation_id: str, from_key: str | None, to_key: str | None) -> int:
475
+ links = self._link_rows()
476
+ query = select(func.count()).where(self._scope(links), links.c.relation_id == relation_id)
477
+ if from_key is not None:
478
+ query = query.where(links.c.from_key == from_key)
479
+ if to_key is not None:
480
+ query = query.where(links.c.to_key == to_key)
481
+ return int(self._execute(query).scalar_one())
482
+
483
+
484
+ def _side(from_key: str | None, to_key: str | None) -> tuple[Side | None, str]:
485
+ if from_key is not None and to_key is None:
486
+ return "from", from_key
487
+ if to_key is not None and from_key is None:
488
+ return "to", to_key
489
+ return None, ""