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,578 @@
1
+ """Neighborhood traversal over the materialized head (design 8.3).
2
+
3
+ `neighborhood` walks breadth first from seed Records along Links, in one SQL statement: a
4
+ recursive CTE whose recursive step joins each frontier Record laterally to its Links (stored
5
+ Links through the two `mat_links` indexes, foreign-key backed Links through the Records and
6
+ their per-Attribute index), at most `fanout` Links per Record and hop. The walk never
7
+ re-enters a Record already on its own path (`CYCLE`), and it is consumed lazily: the outer
8
+ `LIMIT` on Links walked stops the recursion, so caps bound the work, not only the result.
9
+ The same statement hydrates the Records reached, nearest first up to the node cap, and returns
10
+ the Links walked between them: one round trip.
11
+
12
+ Inbound foreign-key hops compare a JSON value, which row-level security keeps from being an
13
+ index condition; with `store.reads.use_definer_functions` the statement runs inside the
14
+ security definer function `lb_neighborhood` (revision 0005), which builds the same statement
15
+ from the Domain, branch and Relations it is given. On an overlay branch the walk reads the
16
+ resolved rows (`lorebase_store.layout`): Links and Records by key through one lateral lookup
17
+ over the overlay's and the base's rows at once (`BranchLayout.links_lookup`,
18
+ `records_lookup`), inbound foreign keys through both halves and their per-Attribute indexes;
19
+ the function is `lb_neighborhood_overlay` (revisions 0010 and 0012).
20
+
21
+ Path Derivations (design 5.8) are followed when named: a materialized one like a stored
22
+ Relation (its Links are rows of `mat_links`), one composed at read time hop by hop. A walk
23
+ that follows a composed path runs one hop at a time: per hop one statement of depth 1 from the
24
+ frontier for the other Relations, and the path's Links of the frontier composed from the
25
+ Links of its steps (`lorebase_store.materialize.links.path_links_of`), at most `fanout` per
26
+ Record. Caps and the result are the same as for the single statement.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ from collections.abc import Mapping, Sequence
33
+ from dataclasses import dataclass
34
+ from typing import Any
35
+
36
+ from sqlalchemy import ColumnElement, Integer, LargeBinary, Text, column, text
37
+ from sqlalchemy.dialects.postgresql import JSONB
38
+ from sqlalchemy.ext.asyncio import AsyncSession
39
+
40
+ from lorebase_core.model import LinkDirection, PathRelation, Relation, Schema
41
+ from lorebase_store.indexes import predicate_sql, sql_literal, values_text
42
+ from lorebase_store.layout import BranchLayout, escape_colons, resolve_layout
43
+ from lorebase_store.materialize.links import path_links_of
44
+ from lorebase_store.reads import (
45
+ MAT_RECORDS,
46
+ RECORD_NAMES,
47
+ Direction,
48
+ LinkRef,
49
+ foreign_key_relations,
50
+ hydrate_records,
51
+ record_columns,
52
+ stored_relation_ids,
53
+ )
54
+ from lorebase_store.repositories.materialized import MaterializedRepository, RecordRow
55
+ from lorebase_store.runtime import StoreRuntime, default_runtime
56
+ from lorebase_store.telemetry import record_definer_call
57
+
58
+ _STEP_COLUMNS = (
59
+ "via_kind, via_key, kind_id, key, relation_id, from_kind, from_key, to_kind, to_key, "
60
+ "link_values, link_hash"
61
+ )
62
+ _NULL_LINK = (
63
+ "CAST(NULL AS text), CAST(NULL AS text), CAST(NULL AS text), CAST(NULL AS text), "
64
+ "CAST(NULL AS text), CAST(NULL AS jsonb), CAST(NULL AS bytea)"
65
+ )
66
+
67
+
68
+ @dataclass(frozen=True, slots=True)
69
+ class Neighbor:
70
+ depth: int
71
+ record: RecordRow
72
+
73
+
74
+ @dataclass(frozen=True, slots=True)
75
+ class Neighborhood:
76
+ """Records reached (nearest first) and the Links walked between them.
77
+
78
+ `truncated` is true when a cap may have cut the walk: the Link or node cap, or a Record
79
+ that had `fanout` Links followed in one hop.
80
+ """
81
+
82
+ records: list[Neighbor]
83
+ links: list[LinkRef]
84
+ truncated: bool
85
+
86
+
87
+ @dataclass(frozen=True, slots=True)
88
+ class Hops:
89
+ """Relations one neighborhood follows: forward (out) and backward (in), each stored or
90
+ foreign-key backed."""
91
+
92
+ stored_out: tuple[Relation, ...]
93
+ stored_in: tuple[Relation, ...]
94
+ foreign_out: tuple[Relation, ...]
95
+ foreign_in: tuple[Relation, ...]
96
+
97
+ def as_json(self) -> str:
98
+ """The `p_relations` argument of `lb_neighborhood`."""
99
+
100
+ def rows(relations: Sequence[Relation], attribute: bool) -> list[list[str | None]]:
101
+ return [
102
+ [r.id, r.from_kind, r.to_kind, *([r.foreign_key_attribute] if attribute else [])]
103
+ for r in relations
104
+ ]
105
+
106
+ return json.dumps(
107
+ {
108
+ "out": rows(self.stored_out, False),
109
+ "in": rows(self.stored_in, False),
110
+ "fk_out": rows(self.foreign_out, True),
111
+ "fk_in": rows(self.foreign_in, True),
112
+ }
113
+ )
114
+
115
+
116
+ class TraversalLimitError(ValueError):
117
+ """A requested depth, seed count or cap exceeds the configured maximum."""
118
+
119
+
120
+ def _within(name: str, value: int, maximum: int) -> int:
121
+ if value < 1:
122
+ raise TraversalLimitError(f"{name} must be positive")
123
+ if value > maximum:
124
+ raise TraversalLimitError(f"{name} {value} exceeds the maximum {maximum}")
125
+ return value
126
+
127
+
128
+ def plan_hops(
129
+ schema: Schema,
130
+ direction: Direction = Direction.BOTH,
131
+ relation_ids: Sequence[str] | None = None,
132
+ *,
133
+ follow: Mapping[str, Direction] | None = None,
134
+ ) -> Hops:
135
+ """The Relations to follow: `direction` over `relation_ids` (all when `None`), or each
136
+ Relation of `follow` (Relation id to direction) in its own direction only when given.
137
+ Materialized path Derivations named there are followed as stored Relations; composed
138
+ ones are not part of the statement (`composed_paths`)."""
139
+ relations = sorted(
140
+ [
141
+ *schema.relations.values(),
142
+ *(p.relation for p in schema.path_relations().values() if p.materialized),
143
+ ],
144
+ key=lambda r: r.id,
145
+ )
146
+ if follow is not None:
147
+ outgoing = {rid for rid, way in follow.items() if way.outgoing}
148
+ incoming = {rid for rid, way in follow.items() if way.incoming}
149
+ else:
150
+ wanted = set(schema.relations) if relation_ids is None else set(relation_ids)
151
+ outgoing = wanted if direction.outgoing else set()
152
+ incoming = wanted if direction.incoming else set()
153
+ out = [r for r in relations if r.id in outgoing]
154
+ into = [r for r in relations if r.id in incoming]
155
+ stored = set(stored_relation_ids(relations))
156
+ return Hops(
157
+ stored_out=tuple(r for r in out if r.id in stored),
158
+ stored_in=tuple(r for r in into if r.id in stored),
159
+ foreign_out=tuple(foreign_key_relations(out)),
160
+ foreign_in=tuple(foreign_key_relations(into)),
161
+ )
162
+
163
+
164
+ def composed_paths(
165
+ schema: Schema, follow: Mapping[str, Direction]
166
+ ) -> dict[str, tuple[PathRelation, Direction]]:
167
+ """Path Derivations of `follow` composed at read time (by id, with the direction)."""
168
+ paths = schema.path_relations()
169
+ return {
170
+ rid: (paths[rid], way)
171
+ for rid, way in sorted(follow.items())
172
+ if rid in paths and not paths[rid].materialized
173
+ }
174
+
175
+
176
+ def _branches(hops: Hops, layout: BranchLayout) -> list[str]:
177
+ """Lateral sources of one hop from the frontier Record `w`."""
178
+
179
+ def links(near: str) -> str:
180
+ if layout.overlay:
181
+ # One lateral lookup reads the overlay's and the base's Links of the frontier key
182
+ # in one index scan (`BranchLayout.links_lookup`).
183
+ found = layout.links_lookup(f"x.relation_id = r.relation_id AND x.{near} = w.key")
184
+ return f"CROSS JOIN LATERAL {found} AS l"
185
+ return (
186
+ "JOIN mat_links l ON l.domain_id = :domain AND l.branch = :branch "
187
+ f"AND l.relation_id = r.relation_id AND l.{near} = w.key"
188
+ )
189
+
190
+ record = (
191
+ f"CROSS JOIN LATERAL {layout.records_lookup('x.kind_id = w.kind_id AND x.key = w.key')} m"
192
+ if layout.overlay
193
+ else "JOIN mat_records m ON m.domain_id = :domain AND m.branch = :branch "
194
+ "AND m.kind_id = w.kind_id AND m.key = w.key"
195
+ )
196
+
197
+ parts: list[str] = []
198
+ if hops.stored_out:
199
+ parts.append(
200
+ "SELECT r.to_kind, l.to_key, l.relation_id, r.from_kind, l.from_key, r.to_kind, "
201
+ f'l.to_key, l."values", l.object_hash FROM out_rels r {links("from_key")} '
202
+ "WHERE r.from_kind = w.kind_id LIMIT :fanout"
203
+ )
204
+ if hops.stored_in:
205
+ parts.append(
206
+ "SELECT r.from_kind, l.from_key, l.relation_id, r.from_kind, l.from_key, r.to_kind, "
207
+ f'l.to_key, l."values", l.object_hash FROM in_rels r {links("to_key")} '
208
+ "WHERE r.to_kind = w.kind_id LIMIT :fanout"
209
+ )
210
+ if hops.foreign_out:
211
+ parts.append(
212
+ 'SELECT f.to_kind, m."values" ->> f.attribute, f.relation_id, f.from_kind, m.key, '
213
+ "f.to_kind, m.\"values\" ->> f.attribute, CAST('{}' AS jsonb), CAST(NULL AS bytea) "
214
+ f"FROM fk_rels f {record} "
215
+ 'WHERE f.from_kind = w.kind_id AND m."values" ->> f.attribute IS NOT NULL '
216
+ "LIMIT :fanout"
217
+ )
218
+ for relation in hops.foreign_in:
219
+ assert relation.foreign_key_attribute is not None
220
+ from_kind = sql_literal(relation.from_kind)
221
+ to_kind = sql_literal(relation.to_kind)
222
+ predicate = predicate_sql(layout.domain_id, layout.branch, relation.from_kind)
223
+ parts.append(
224
+ f"SELECT CAST({from_kind} AS text), key, CAST({sql_literal(relation.id)} AS text), "
225
+ f"CAST({from_kind} AS text), key, CAST({to_kind} AS text), w.key, "
226
+ f"CAST('{{}}' AS jsonb), CAST(NULL AS bytea) FROM {layout.records_from()} "
227
+ f"WHERE w.kind_id = {to_kind} AND {escape_colons(predicate)} "
228
+ f"AND {values_text(relation.foreign_key_attribute)} = w.key LIMIT :fanout"
229
+ )
230
+ return parts
231
+
232
+
233
+ def neighborhood_sql(
234
+ schema: Schema,
235
+ domain_id: str,
236
+ branch: str | BranchLayout,
237
+ direction: Direction,
238
+ relation_ids: Sequence[str] | None,
239
+ *,
240
+ follow: Mapping[str, Direction] | None = None,
241
+ ) -> tuple[str, dict[str, Any]]:
242
+ """The neighborhood statement and its schema-derived parameters (see `plan_hops`).
243
+
244
+ `follow` (Relation id to direction), when given, replaces `direction` and
245
+ `relation_ids`: each Relation is followed in its own direction only.
246
+
247
+ Other parameters: `:domain`, `:branch`, `:seed_kinds`, `:seed_keys`, `:depth`,
248
+ `:fanout`, `:max_steps` (Links walked plus seeds) and `:max_nodes`. `lb_neighborhood`
249
+ builds the same statement. A branch given by name is read as a full branch.
250
+ """
251
+ layout = branch if isinstance(branch, BranchLayout) else BranchLayout(domain_id, branch)
252
+ hops = plan_hops(schema, direction, relation_ids, follow=follow)
253
+ params: dict[str, Any] = {
254
+ "out_ids": [r.id for r in hops.stored_out],
255
+ "out_from": [r.from_kind for r in hops.stored_out],
256
+ "out_to": [r.to_kind for r in hops.stored_out],
257
+ "in_ids": [r.id for r in hops.stored_in],
258
+ "in_from": [r.from_kind for r in hops.stored_in],
259
+ "in_to": [r.to_kind for r in hops.stored_in],
260
+ "fk_ids": [r.id for r in hops.foreign_out],
261
+ "fk_from": [r.from_kind for r in hops.foreign_out],
262
+ "fk_to": [r.to_kind for r in hops.foreign_out],
263
+ "fk_attributes": [r.foreign_key_attribute for r in hops.foreign_out],
264
+ }
265
+ parts = _branches(hops, layout)
266
+ step = (
267
+ "SELECT w.depth + 1, w.kind_id, w.key, n.* FROM walk w CROSS JOIN LATERAL "
268
+ "(SELECT * FROM ("
269
+ + " UNION ALL ".join(f"({part})" for part in parts)
270
+ + ") AS hop LIMIT :fanout) AS n WHERE w.depth < :depth"
271
+ if parts
272
+ else f"SELECT w.depth + 1, w.kind_id, w.key, w.kind_id, w.key, {_NULL_LINK} "
273
+ "FROM walk w WHERE false"
274
+ )
275
+ alias = "x" if layout.overlay else "m"
276
+ found = f"{alias}.kind_id = n.kind_id AND {alias}.key = n.key"
277
+ hydrate = (
278
+ # A lateral lookup per node over the overlay's and the base's rows at once.
279
+ f"CROSS JOIN LATERAL {layout.records_lookup(found)} AS m"
280
+ if layout.overlay
281
+ else f"JOIN mat_records m ON m.domain_id = :domain AND m.branch = :branch AND {found}"
282
+ )
283
+ sql = f"""
284
+ WITH RECURSIVE
285
+ out_rels(relation_id, from_kind, to_kind) AS (
286
+ SELECT * FROM unnest(CAST(:out_ids AS text[]), CAST(:out_from AS text[]),
287
+ CAST(:out_to AS text[]))),
288
+ in_rels(relation_id, from_kind, to_kind) AS (
289
+ SELECT * FROM unnest(CAST(:in_ids AS text[]), CAST(:in_from AS text[]),
290
+ CAST(:in_to AS text[]))),
291
+ fk_rels(relation_id, from_kind, to_kind, attribute) AS (
292
+ SELECT * FROM unnest(CAST(:fk_ids AS text[]), CAST(:fk_from AS text[]),
293
+ CAST(:fk_to AS text[]), CAST(:fk_attributes AS text[]))),
294
+ walk(depth, {_STEP_COLUMNS}) AS (
295
+ SELECT 0, CAST(NULL AS text), CAST(NULL AS text), s.kind_id, s.key, {_NULL_LINK}
296
+ FROM unnest(CAST(:seed_kinds AS text[]), CAST(:seed_keys AS text[])) AS s(kind_id, key)
297
+ UNION ALL
298
+ {step}
299
+ ) CYCLE kind_id, key SET is_cycle USING path,
300
+ walked AS MATERIALIZED (SELECT depth, {_STEP_COLUMNS} FROM walk LIMIT :max_steps),
301
+ nodes AS MATERIALIZED (
302
+ SELECT kind_id, key, min(depth) AS depth FROM walked GROUP BY kind_id, key
303
+ ORDER BY min(depth), kind_id, key LIMIT :max_nodes + 1),
304
+ links AS (
305
+ SELECT DISTINCT ON (relation_id, from_key, to_key) relation_id, from_key, to_key,
306
+ link_values, link_hash
307
+ FROM walked w
308
+ WHERE relation_id IS NOT NULL
309
+ AND EXISTS (SELECT 1 FROM nodes n WHERE n.kind_id = w.from_kind AND n.key = w.from_key)
310
+ AND EXISTS (SELECT 1 FROM nodes n WHERE n.kind_id = w.to_kind AND n.key = w.to_key))
311
+ SELECT 'record' AS item, n.depth, {record_columns()},
312
+ NULL AS relation_id, NULL AS from_key, NULL AS to_key,
313
+ CAST(NULL AS jsonb) AS link_values, CAST(NULL AS bytea) AS link_hash
314
+ FROM nodes n
315
+ {hydrate}
316
+ UNION ALL
317
+ SELECT 'link', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
318
+ relation_id, from_key, to_key, link_values, link_hash
319
+ FROM links
320
+ UNION ALL
321
+ SELECT 'steps', (SELECT count(*) FROM walked), NULL, NULL, NULL, NULL, NULL, NULL, NULL,
322
+ NULL, NULL, NULL, NULL, NULL, NULL
323
+ UNION ALL
324
+ SELECT 'fanout', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
325
+ NULL, NULL
326
+ WHERE EXISTS (
327
+ SELECT 1 FROM walked WHERE depth > 0 GROUP BY depth, via_kind, via_key
328
+ HAVING count(DISTINCT (relation_id, from_key, to_key)) >= :fanout)
329
+ """
330
+ return sql, params
331
+
332
+
333
+ _RESULT_COLUMNS: tuple[ColumnElement[Any], ...] = (
334
+ column("item", Text),
335
+ column("depth", Integer),
336
+ *(MAT_RECORDS.c[name] for name in RECORD_NAMES),
337
+ column("relation_id", Text),
338
+ column("from_key", Text),
339
+ column("to_key", Text),
340
+ column("link_values", JSONB),
341
+ column("link_hash", LargeBinary),
342
+ )
343
+ _DEFINER = {
344
+ function: text(
345
+ f"SELECT item, depth, {record_columns(None)}, relation_id, from_key, to_key, "
346
+ f"link_values, link_hash FROM {function}(:domain, :branch, CAST(:seeds AS jsonb), "
347
+ "CAST(:depth AS integer), CAST(:relations AS jsonb), CAST(:fanout AS integer), "
348
+ "CAST(:max_steps AS integer), CAST(:max_nodes AS integer))"
349
+ ).columns(*_RESULT_COLUMNS)
350
+ for function in ("lb_neighborhood", "lb_neighborhood_overlay")
351
+ }
352
+
353
+
354
+ @dataclass(frozen=True, slots=True)
355
+ class _Walked:
356
+ records: list[Neighbor]
357
+ links: list[LinkRef]
358
+ steps: int
359
+ saturated: bool
360
+
361
+
362
+ async def neighborhood(
363
+ session: AsyncSession,
364
+ domain_id: str,
365
+ branch: str | BranchLayout,
366
+ schema: Schema,
367
+ seeds: Sequence[tuple[str, str]],
368
+ *,
369
+ depth: int = 2,
370
+ direction: Direction = Direction.BOTH,
371
+ relation_ids: Sequence[str] | None = None,
372
+ follow: Mapping[str, Direction] | None = None,
373
+ fanout: int | None = None,
374
+ max_nodes: int | None = None,
375
+ max_links: int | None = None,
376
+ runtime: StoreRuntime | None = None,
377
+ ) -> Neighborhood:
378
+ """Records within `depth` hops of `seeds` (`(kind_id, key)` pairs) and the Links between.
379
+
380
+ Follows `direction` over `relation_ids` (every Relation when `None`), or exactly the
381
+ `(relation id, direction)` pairs of `follow`; path Derivations are followed when named
382
+ there (their id is the Relation id). Caps default to and may not exceed
383
+ `store.traverse`. Seeds that do not exist are absent from the result.
384
+ """
385
+ runtime = runtime or default_runtime()
386
+ caps = runtime.settings.traverse
387
+ layout = await resolve_layout(session, domain_id, branch)
388
+ seeds = list(dict.fromkeys(seeds))
389
+ _within("seed count", len(seeds), caps.max_seeds)
390
+ nodes_cap = _within("max_nodes", max_nodes or caps.max_nodes, caps.max_nodes)
391
+ links_cap = _within("max_links", max_links or caps.max_links, caps.max_links)
392
+ bounds: dict[str, Any] = {
393
+ "domain": domain_id,
394
+ "branch": layout.branch,
395
+ "depth": _within("depth", depth, caps.max_depth),
396
+ "fanout": _within("fanout", fanout or caps.max_fanout, caps.max_fanout),
397
+ "max_steps": links_cap + len(seeds),
398
+ "max_nodes": nodes_cap,
399
+ }
400
+ if follow is None and relation_ids is not None:
401
+ follow = dict.fromkeys(relation_ids, direction)
402
+ composed = {} if follow is None else composed_paths(schema, follow)
403
+ if follow is not None and composed:
404
+ walked = await _hop_by_hop(
405
+ session, layout, schema, seeds, follow, composed, bounds, runtime
406
+ )
407
+ else:
408
+ walked = await _walk(session, layout, schema, seeds, direction, follow, bounds, runtime)
409
+ records = sorted(walked.records, key=lambda n: (n.depth, n.record.kind_id, n.record.key))
410
+ truncated = walked.saturated or walked.steps >= bounds["max_steps"] or len(records) > nodes_cap
411
+ if len(records) > nodes_cap:
412
+ records = records[:nodes_cap]
413
+ kept = {(n.record.kind_id, n.record.key) for n in records}
414
+ links = [link for link in walked.links if _between(schema, link, kept)]
415
+ links.sort(key=lambda link: (link.relation_id, link.from_key, link.to_key))
416
+ return Neighborhood(records, links, truncated)
417
+
418
+
419
+ async def _walk(
420
+ session: AsyncSession,
421
+ layout: BranchLayout,
422
+ schema: Schema,
423
+ seeds: Sequence[tuple[str, str]],
424
+ direction: Direction,
425
+ follow: Mapping[str, Direction] | None,
426
+ bounds: Mapping[str, Any],
427
+ runtime: StoreRuntime,
428
+ ) -> _Walked:
429
+ """One neighborhood statement over Relations and materialized paths."""
430
+ if runtime.settings.reads.use_definer_functions:
431
+ hops = plan_hops(schema, direction, None, follow=follow)
432
+ params = {
433
+ **bounds,
434
+ "seeds": json.dumps([list(seed) for seed in seeds]),
435
+ "relations": hops.as_json(),
436
+ }
437
+ function = "lb_neighborhood_overlay" if layout.overlay else "lb_neighborhood"
438
+ record_definer_call(function)
439
+ statement = _DEFINER[function]
440
+ else:
441
+ sql, params = neighborhood_sql(
442
+ schema, layout.domain_id, layout, direction, None, follow=follow
443
+ )
444
+ params.update(
445
+ bounds,
446
+ seed_kinds=[kind for kind, _ in seeds],
447
+ seed_keys=[key for _, key in seeds],
448
+ )
449
+ statement = text(sql).columns(*_RESULT_COLUMNS)
450
+ records: list[Neighbor] = []
451
+ links: list[LinkRef] = []
452
+ steps = 0
453
+ saturated = False
454
+ for row in (await session.execute(statement, params)).mappings():
455
+ if row["item"] == "record":
456
+ record = RecordRow(**{name: row[name] for name in RECORD_NAMES})
457
+ records.append(Neighbor(row["depth"], record))
458
+ elif row["item"] == "link":
459
+ links.append(
460
+ LinkRef(
461
+ row["relation_id"],
462
+ row["from_key"],
463
+ row["to_key"],
464
+ row["link_values"],
465
+ row["link_hash"],
466
+ )
467
+ )
468
+ elif row["item"] == "fanout":
469
+ saturated = True
470
+ else:
471
+ steps = row["depth"]
472
+ return _Walked(records, links, steps, saturated)
473
+
474
+
475
+ async def _hop_by_hop(
476
+ session: AsyncSession,
477
+ layout: BranchLayout,
478
+ schema: Schema,
479
+ seeds: Sequence[tuple[str, str]],
480
+ follow: Mapping[str, Direction],
481
+ composed: Mapping[str, tuple[PathRelation, Direction]],
482
+ bounds: Mapping[str, Any],
483
+ runtime: StoreRuntime,
484
+ ) -> _Walked:
485
+ """A walk following composed path Derivations, one hop at a time (module docstring):
486
+ `steps` counts the seeds and the Links walked, like the statement's."""
487
+ settings = runtime.settings
488
+ repository = MaterializedRepository(
489
+ session, layout.domain_id, layout.branch, writes=settings.writes, layout=layout
490
+ )
491
+ others = {rid: way for rid, way in follow.items() if rid not in composed}
492
+ fanout = int(bounds["fanout"])
493
+ max_steps = int(bounds["max_steps"])
494
+ max_nodes = int(bounds["max_nodes"])
495
+ depths: dict[tuple[str, str], int] = dict.fromkeys(seeds, 0)
496
+ rows: dict[tuple[str, str], RecordRow] = {}
497
+ links: dict[tuple[str, str, str], LinkRef] = {}
498
+ frontier = list(seeds)
499
+ steps = len(seeds)
500
+ saturated = False
501
+ for level in range(1, int(bounds["depth"]) + 1):
502
+ if not frontier or steps >= max_steps or len(depths) > max_nodes:
503
+ break
504
+ reached: list[tuple[str, str]] = []
505
+ if others:
506
+ hop = await _walk(
507
+ session,
508
+ layout,
509
+ schema,
510
+ frontier,
511
+ Direction.BOTH,
512
+ others,
513
+ {
514
+ **bounds,
515
+ "depth": 1,
516
+ "max_steps": max_steps - steps + len(frontier),
517
+ "max_nodes": max_nodes + len(frontier),
518
+ },
519
+ runtime,
520
+ )
521
+ saturated = saturated or hop.saturated
522
+ steps += max(hop.steps - len(frontier), 0)
523
+ for neighbor in hop.records:
524
+ identity = (neighbor.record.kind_id, neighbor.record.key)
525
+ rows.setdefault(identity, neighbor.record)
526
+ if neighbor.depth == 1:
527
+ reached.append(identity)
528
+ for link in hop.links:
529
+ links.setdefault((link.relation_id, link.from_key, link.to_key), link)
530
+ for path_id, (path, way) in composed.items():
531
+ for along in _directions(way):
532
+ keys = sorted(
533
+ {key for kind, key in frontier if kind == path.relation.endpoint(along)}
534
+ )
535
+ if not keys:
536
+ continue
537
+ if steps >= max_steps or len(depths.keys() | reached) > max_nodes:
538
+ saturated = True # Links left unwalked
539
+ continue
540
+ far_kind = path.relation.far_endpoint(along)
541
+ found = await path_links_of(
542
+ repository, path, along, keys, settings.traverse.path_fanout
543
+ )
544
+ for near, items in sorted(found.items()):
545
+ # The walk's fan-out and step budget, not the path's own bound
546
+ # (`store.traverse.path_fanout`), which is part of what the path is.
547
+ room = min(fanout, max(max_steps - steps, 0))
548
+ saturated = saturated or len(items) > room
549
+ for far, _ in items[:room]:
550
+ pair = (near, far) if along is LinkDirection.FORWARD else (far, near)
551
+ links.setdefault((path_id, *pair), LinkRef(path_id, *pair, {}, None))
552
+ reached.append((far_kind, far))
553
+ steps += 1
554
+ frontier = []
555
+ for identity in reached:
556
+ if identity not in depths:
557
+ depths[identity] = level
558
+ frontier.append(identity)
559
+ missing = [identity for identity in depths if identity not in rows]
560
+ rows.update(await hydrate_records(session, layout.domain_id, layout, missing, runtime=runtime))
561
+ records = [
562
+ Neighbor(depth, rows[identity]) for identity, depth in depths.items() if identity in rows
563
+ ]
564
+ return _Walked(records, list(links.values()), steps, saturated)
565
+
566
+
567
+ def _directions(way: Direction) -> list[LinkDirection]:
568
+ return [
569
+ *([LinkDirection.FORWARD] if way.outgoing else []),
570
+ *([LinkDirection.INVERSE] if way.incoming else []),
571
+ ]
572
+
573
+
574
+ def _between(schema: Schema, link: LinkRef, kept: set[tuple[str, str]]) -> bool:
575
+ relation = schema.link_relation(link.relation_id)
576
+ if relation is None:
577
+ return False
578
+ return (relation.from_kind, link.from_key) in kept and (relation.to_kind, link.to_key) in kept
@@ -0,0 +1,50 @@
1
+ """The personal data vault (design 13.5): settings, keys, storage, erasure and key rotation."""
2
+
3
+ from lorebase_store.vault.keys import VaultKeys
4
+ from lorebase_store.vault.quotes import QuoteLimits, has_vault_values, quoted_values
5
+ from lorebase_store.vault.rotation import KeyUsage, RotationReport, rotate, usage
6
+ from lorebase_store.vault.settings import ErasedValues, VaultSettings, vault_settings
7
+ from lorebase_store.vault.store import (
8
+ Lineage,
9
+ Tokenized,
10
+ VaultRow,
11
+ VaultState,
12
+ VaultValue,
13
+ erase_matching,
14
+ erase_record,
15
+ insert_lineage,
16
+ insert_rows,
17
+ linked_keys,
18
+ matching_keys,
19
+ open_values,
20
+ tokenize,
21
+ vault_attributes,
22
+ )
23
+
24
+ __all__ = [
25
+ "ErasedValues",
26
+ "KeyUsage",
27
+ "Lineage",
28
+ "QuoteLimits",
29
+ "RotationReport",
30
+ "Tokenized",
31
+ "VaultKeys",
32
+ "VaultRow",
33
+ "VaultSettings",
34
+ "VaultState",
35
+ "VaultValue",
36
+ "erase_matching",
37
+ "erase_record",
38
+ "has_vault_values",
39
+ "insert_lineage",
40
+ "insert_rows",
41
+ "linked_keys",
42
+ "matching_keys",
43
+ "open_values",
44
+ "quoted_values",
45
+ "rotate",
46
+ "tokenize",
47
+ "usage",
48
+ "vault_attributes",
49
+ "vault_settings",
50
+ ]